* fix(ui): smart resolution + existence-validation for code-file paths
The bare-prose / backtick path detector linkifies anything that looks
like a code path. Two failure modes regularly produce dead links: prose
abbreviations like `editor/App.tsx` (real file is
`packages/editor/App.tsx`) and references to files the plan proposes
but hasn't created yet. Both 404 on click with no UX cue.
Resolves abbreviated paths via a case-insensitive suffix-match against
a cached project walk (`resolveCodeFile` in `packages/shared/resolve-file.ts`),
mirroring what `resolveMarkdownFile` already does for markdown. The walk
is pre-warmed when the plan/annotate server boots and on every
`/api/doc` request, with a 30s TTL so newly-created files can resolve
mid-review. Storing the walk as a Promise makes the cache race-safe —
concurrent callers piggyback rather than starting a second walk.
A new `POST /api/doc/exists` endpoint takes a batch of candidate paths
and reports `found` / `ambiguous` / `missing` / `unavailable` per path.
On the frontend, `useValidatedCodePaths` extracts candidates from the
markdown on load and POSTs once. The renderer reads the result via
`CodePathValidationContext`: `found` opens directly with the resolved
absolute path, `ambiguous` opens a `CodeFilePicker` popover listing all
matches (common in monorepos where `App.tsx` exists in several
packages), `missing` demotes the link to plain code, and `unavailable`
falls back to the optimistic linkification we have today. While
validation is in flight, every detected path renders as a link, so
first paint is unchanged.
The detection itself gets a shape filter (`isPlausibleCodeFilePath`)
that hard-rejects shell brace expansion (`{a,b}`), glob wildcards, and
whitespace, while explicitly allowing `[` / `]` so Next.js dynamic
routes (`app/[slug]/page.tsx`) still resolve. The bare-prose regex moves
out of `InlineMarkdown.tsx` into `code-file.ts` so the renderer and the
new server-side extractor use the same source of truth, and the
extractor strips fenced code blocks, HTML comments, and URL ranges
before scanning so it only emits candidates the renderer would actually
paint.
Pi extension mirrors the Bun changes (handler upgrade, pre-warm,
`/api/doc/exists` route). When the popout's `/api/doc` request 404s the
dialog now surfaces "File not found in repo: <path>" instead of
silently swallowing the error.
Tests: `code-file.test.ts` extended with shape-filter and Next.js-route
cases; new `extract-code-paths.test.ts` covers extraction, dedup,
fenced/HTML/URL exclusion, and the URL-with-parens regression; new
`resolve-file.test.ts` covers the suffix-match strategy, leading `./`
handling, ambiguous results, and ignored-dir behavior.
* fix(ui): thread doc-base through code-path validator
Out-of-tree linked docs (and annotate-mode files outside cwd) reference
files relative to themselves. The validator was resolving against cwd
only, so those paths got marked missing and the renderer demoted them
to plain text — even though clicks still resolved correctly via base.
Also tightens the suffix-match's leading-segment strip so `../foo.ts`
no longer silently misresolves to an unrelated `foo.ts` in cwd.
Cleanup: delete unused extract-code-paths import in reference-handlers,
add the export entry to packages/shared so consumers don't rely on
Bun's lenient subpath resolution. Add TODO(security) comments at both
handleDocExists sites flagging that absolute paths bypass project-root
containment.
223 tests pass (3 new resolver cases for baseDir + ../ regression).
* refactor(editor): dedupe activeDocBaseDir; expand security TODO
Self-review fallout:
1. The doc-base expression `linkedDocHook.filepath ? dirname(...) :
imageBaseDir` lived in two places (click-time URL builder and Viewer
prop). If they drift, validator and click resolve against different
bases and we silently re-introduce the demote-correct-link bug.
Extract to a single useMemo.
2. The handleDocExists security TODO mentioned absolute paths in
`paths[]` but I just added `base` acceptance, which has the same
shape of leak (hostile sender supplies base=/secret/dir + relative
path). Both vectors flagged in one TODO, mirrored Bun + Pi.
223 tests pass; both builds clean.
* fix(ui): code-file popout shows real error; misc consistency
Review fallout:
- `CodeFilePopout` hardcoded "File not found in repo" regardless of
cause. The hook already captures the server's error string, so an
ambiguous-path 400 (which can happen if a user clicks an optimistic
link before validation completes) was surfacing as a misleading
not-found message. Render the actual `error` and only show the
planned/future-file caveat when the error matches "file not found".
- `InlineMarkdown` emitted demoted bare-prose paths as raw strings
while every other plain-text branch in `emitPlainTextWithBareUrls`
routes through `transformPlainText`. Cosmetic-only today since
paths rarely contain transformable content, but the divergence
invites copy-paste rot. Routed through the same helper.
- CLAUDE.md missed the new POST /api/doc/exists endpoint in both
Plan Server and Annotate Server tables. Added.
223 tests pass; both builds clean.
* fix(ui): demote paths the extractor excluded from validation
When the validator is ready but a candidate path has no entry in the
validated map, the extractor intentionally excluded it — e.g. inside
an HTML comment or fenced code block. The renderer was optimistically
linking these because gateCodePath returned 'link' for missing entries.
Found during manual testing: `<!-- packages/editor/App.tsx -->` inside
a paragraph (parser doesn't recognize HTML comments as block-level)
was rendered as a clickable link. The extractor correctly stripped the
comment, but the renderer's optimistic fallback overrode that.
Also adds manual test harness: tests/manual/path-detection/ with
sandbox setup + three launcher scripts (plan mode, annotate in-tree,
annotate out-of-tree) covering ~30 test cases.
223 tests pass; both builds clean.
* fix(ui): skip HTML comments in InlineMarkdown scanner
The parser doesn't recognize <!-- --> as block-level HTML, so comments
inside paragraphs fall through to InlineMarkdown. The scanner then
finds paths inside the comment text and linkifies them.
The previous gateCodePath fix (demote when not in validated map) didn't
help here because the same path appeared elsewhere in the document —
the map had an entry from the non-comment occurrence.
Fix: match <!-- ... --> at the top of the scanner loop and skip the
entire comment. HTML comments should be invisible per CommonMark spec.
* fix: handle unavailable variant in markdown resolve narrowing
The shared ResolveResult type gained an `unavailable` variant for code
files. The markdown resolver never returns it, but TS can't narrow
past it without an explicit guard. Both Bun and Pi handlers now guard
`not_found || unavailable` before accessing `result.path`.
31 KiB
Plannotator
A plan review UI for Claude Code that intercepts ExitPlanMode via hooks, letting users approve or request changes with annotated feedback. Also provides code review for git diffs and annotation of arbitrary markdown files.
Project Structure
plannotator/
├── apps/
│ ├── hook/ # Claude Code plugin
│ │ ├── .claude-plugin/plugin.json
│ │ ├── commands/ # Slash commands (plannotator-review.md, plannotator-annotate.md)
│ │ ├── hooks/hooks.json # PermissionRequest hook config
│ │ ├── server/index.ts # Entry point (plan + review + annotate + archive subcommands)
│ │ └── dist/ # Built single-file apps (index.html, review.html)
│ ├── opencode-plugin/ # OpenCode plugin
│ │ ├── commands/ # Slash commands (plannotator-review.md, plannotator-annotate.md)
│ │ ├── index.ts # Plugin entry with submit_plan tool + review/annotate event handlers
│ │ ├── plannotator.html # Built plan review app
│ │ └── review-editor.html # Built code review app
│ ├── marketing/ # Marketing site, docs, and blog (plannotator.ai)
│ │ └── astro.config.mjs # Astro 5 static site with content collections
│ ├── paste-service/ # Paste service for short URL sharing
│ │ ├── core/ # Platform-agnostic logic (handler, storage interface, cors)
│ │ ├── stores/ # Storage backends (fs, kv, s3)
│ │ └── targets/ # Deployment entries (bun.ts, cloudflare.ts)
│ ├── review/ # Standalone review server (for development)
│ │ ├── index.html
│ │ ├── index.tsx
│ │ └── vite.config.ts
│ └── vscode-extension/ # VS Code extension — opens plans in editor tabs
│ ├── bin/ # Router scripts (open-in-vscode, xdg-open)
│ ├── src/ # extension.ts, cookie-proxy.ts, ipc-server.ts, panel-manager.ts, editor-annotations.ts, vscode-theme.ts
│ └── package.json # Extension manifest (publisher: backnotprop)
├── packages/
│ ├── server/ # Shared server implementation
│ │ ├── index.ts # startPlannotatorServer(), handleServerReady()
│ │ ├── review.ts # startReviewServer(), handleReviewServerReady()
│ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady()
│ │ ├── storage.ts # Re-exports from @plannotator/shared/storage
│ │ ├── share-url.ts # Server-side share URL generation for remote sessions
│ │ ├── remote.ts # isRemoteSession(), getServerPort()
│ │ ├── browser.ts # openBrowser()
│ │ ├── draft.ts # Re-exports from @plannotator/shared/draft
│ │ ├── integrations.ts # Obsidian, Bear integrations
│ │ ├── ide.ts # VS Code diff integration (openEditorDiff)
│ │ ├── editor-annotations.ts # VS Code editor annotation endpoints
│ │ └── project.ts # Project name detection for tags
│ ├── ui/ # Shared React components + theme
│ │ ├── theme.css # Single source of truth for color tokens + Tailwind bridge
│ │ ├── components/ # Viewer, Toolbar, Settings, etc.
│ │ │ ├── icons/ # Shared SVG icon components (themeIcons, etc.)
│ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views
│ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser
│ │ ├── shortcuts/ # Keyboard shortcut registry (see Keyboard Shortcuts section below)
│ │ │ ├── core.ts # Engine: parser, formatter, dispatcher, validator
│ │ │ ├── runtime.ts # Engine: useShortcutScope, useDoubleTapShortcuts hooks
│ │ │ ├── index.ts # Barrel — re-exports engine + scopes from both subfolders
│ │ │ ├── plan-review/ # Scopes for plan-editor surfaces (annotationToolbar, annotationPanel, commentPopover, imageAnnotator, inputMethod, viewer)
│ │ │ └── code-review/ # Scopes for review-editor surfaces (ai, allFilesDiff, annotationToolbar, fileTree, prComments, suggestionModal, tourDialog)
│ │ ├── shortcuts.test.ts # Registry unit tests (parser, dispatcher, validator)
│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts
│ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts
│ │ └── types.ts
│ ├── ai/ # Provider-agnostic AI backbone (providers, sessions, endpoints)
│ ├── shared/ # Shared types, utilities, and cross-runtime logic
│ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only)
│ │ ├── draft.ts # Annotation draft persistence (node:fs only)
│ │ └── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName)
│ ├── editor/ # Plan review app
│ │ ├── App.tsx # Main plan review app
│ │ └── shortcuts.ts # planReviewSurface + annotateSurface — composes plan-review scopes into per-surface registries
│ └── review-editor/ # Code review UI
│ ├── App.tsx # Main review app
│ ├── shortcuts.ts # codeReviewSurface — composes code-review scopes into the review registry
│ ├── components/ # DiffViewer, FileTree, ReviewSidebar
│ ├── dock/ # Dockview center panel infrastructure
│ ├── demoData.ts # Demo diff for standalone mode
│ └── index.css # Review-specific styles
├── .claude-plugin/marketplace.json # For marketplace install
└── legacy/ # Old pre-monorepo code (reference only)
Server Runtimes
There are two separate server implementations with the same API surface:
- Bun server (
packages/server/) — used by both Claude Code (apps/hook/) and OpenCode (apps/opencode-plugin/). These plugins import directly from@plannotator/server. - Pi server (
apps/pi-extension/server/) — a standalone Node.js server for the Pi extension. It mirrors the Bun server's API but usesnode:httpprimitives instead of Bun'sRequest/ResponseAPIs.
When adding or modifying server endpoints, both implementations must be updated. Runtime-agnostic logic (store, validation, types) lives in packages/shared/ and is imported by both.
Installation
Via plugin marketplace (when repo is public):
/plugin marketplace add backnotprop/plannotator
Local testing:
claude --plugin-dir ./apps/hook
Environment Variables
| Variable | Description |
|---|---|
PLANNOTATOR_REMOTE |
Set to 1 / true for remote mode, 0 / false for local mode, or leave unset for SSH auto-detection. Uses a fixed port in remote mode; browser-opening behavior depends on the environment. |
PLANNOTATOR_PORT |
Fixed port to use. Default: random locally, 19432 for remote sessions. |
PLANNOTATOR_BROWSER |
Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. |
PLANNOTATOR_SHARE |
Set to disabled to turn off URL sharing entirely. Default: enabled. |
PLANNOTATOR_SHARE_URL |
Custom base URL for share links (self-hosted portal). Default: https://share.plannotator.ai. |
PLANNOTATOR_PASTE_URL |
Base URL of the paste service API for short URL sharing. Default: https://plannotator-paste.plannotator.workers.dev. |
PLANNOTATOR_ORIGIN |
Explicit agent-origin override at the top of the detection chain. Valid values: claude-code, opencode, codex, copilot-cli, gemini-cli. Invalid values silently fall through to env-based detection. Unset by default. |
PLANNOTATOR_JINA |
Set to 0 / false to disable Jina Reader for URL annotation, or 1 / true to enable. Default: enabled. Can also be set via ~/.plannotator/config.json ({ "jina": false }) or per-invocation via --no-jina. |
JINA_API_KEY |
Optional Jina Reader API key for higher rate limits (500 RPM vs 20 RPM unauthenticated). Free keys include 10M tokens. |
PLANNOTATOR_VERIFY_ATTESTATION |
Read by the install scripts only, not by the runtime binary. Set to 1 / true to have scripts/install.sh / install.ps1 / install.cmd run gh attestation verify on every install. Off by default. Can also be set persistently via ~/.plannotator/config.json ({ "verifyAttestation": true }) or per-invocation via --verify-attestation. Requires gh installed and authenticated. |
Legacy: SSH_TTY and SSH_CONNECTION are still detected when PLANNOTATOR_REMOTE is unset. Set PLANNOTATOR_REMOTE=1 / true to force remote mode or 0 / false to force local mode.
Devcontainer/SSH usage:
export PLANNOTATOR_REMOTE=1
export PLANNOTATOR_PORT=9999
Plan Review Flow
Claude calls ExitPlanMode
↓
PermissionRequest hook fires
↓
Bun server reads plan from stdin JSON (tool_input.plan)
↓
Server starts on random port, opens browser
↓
User reviews plan, optionally adds annotations
↓
Approve → stdout: {"hookSpecificOutput":{"decision":{"behavior":"allow"}}}
Deny → stdout: {"hookSpecificOutput":{"decision":{"behavior":"deny","message":"..."}}}
Code Review Flow
User runs /plannotator-review command
↓
Claude Code: plannotator review subcommand runs
OpenCode: event handler intercepts command
↓
git diff captures unstaged changes
↓
Review server starts, opens browser with diff viewer
↓
User annotates code, provides feedback
↓
Send Feedback → feedback sent to agent session
Approve → "LGTM" sent to agent session
Annotate Flow
User runs /plannotator-annotate <file.md | file.html | https://... | folder/>
↓
Claude Code: plannotator annotate subcommand runs
OpenCode/Pi: event handler intercepts command
↓
Input type detected:
.md/.mdx → file read from disk
.html/.htm → file read, converted to markdown via Turndown
https:// → fetched via Jina Reader (default) or fetch+Turndown (--no-jina)
folder/ → file browser opened, files converted on demand
↓
Annotate server starts (reuses plan editor HTML with mode:"annotate")
↓
User annotates content, provides feedback
↓
Send Annotations → feedback sent to agent session
Archive Flow
User runs plannotator archive (CLI) or /plannotator-archive (Pi)
↓
Server starts in mode:"archive", reads ~/.plannotator/plans/
↓
Browser opens read-only archive viewer (sharing disabled)
↓
User browses saved plan decisions with approved/denied badges
↓
Done → POST /api/done closes the browser
During normal plan review, an Archive sidebar tab provides the same browsing via linked doc overlay without leaving the current session.
Server API
Plan Server (packages/server/index.ts)
| Endpoint | Method | Purpose |
|---|---|---|
/api/plan |
GET | Returns { plan, origin, previousPlan, versionInfo } (plan mode) or { plan, origin, mode: "archive", archivePlans } (archive mode) |
/api/plan/version |
GET | Fetch specific version (?v=N) |
/api/plan/versions |
GET | List all versions of current plan |
/api/archive/plans |
GET | List archived plan decisions (?customPath=) |
/api/archive/plan |
GET | Fetch archived plan content (?filename=&customPath=) |
/api/done |
POST | Close archive browser (archive mode only) |
/api/approve |
POST | Approve plan (body: planSave, agentSwitch, obsidian, bear, feedback) |
/api/deny |
POST | Deny plan (body: feedback, planSave) |
/api/image |
GET | Serve image by path query param |
/api/upload |
POST | Upload image, returns { path, originalName } |
/api/obsidian/vaults |
GET | Detect available Obsidian vaults |
/api/reference/obsidian/files |
GET | List vault markdown files as nested tree (?vaultPath=<path>) |
/api/reference/obsidian/doc |
GET | Read a vault markdown file (?vaultPath=<path>&path=<file>) |
/api/plan/vscode-diff |
POST | Open diff in VS Code (body: baseVersion) |
/api/doc |
GET | Serve linked .md/.mdx file (?path=<path>) |
/api/doc/exists |
POST | Batch-validate code-file paths (body: { paths: string[], base?: string }) returns { results: { [path]: { status: "found"|"ambiguous"|"missing"|"unavailable", … } } } |
/api/draft |
GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
/api/editor-annotations |
GET | List editor annotations (VS Code only) |
/api/editor-annotation |
POST/DELETE | Add or remove an editor annotation (VS Code only) |
/api/external-annotations/stream |
GET | SSE stream for real-time external annotations |
/api/external-annotations |
GET | Snapshot of external annotations (polling fallback, ?since=N for version gating) |
/api/external-annotations |
POST | Add external annotations (single or batch { annotations: [...] }) |
/api/external-annotations |
PATCH | Update fields on a single annotation (?id=) |
/api/external-annotations |
DELETE | Remove by ?id=, ?source=, or clear all |
Review Server (packages/server/review.ts)
| Endpoint | Method | Purpose |
|---|---|---|
/api/diff |
GET | Returns { rawPatch, gitRef, origin, diffType, base, hideWhitespace, gitContext } |
/api/diff/switch |
POST | Switch diff type, base branch, or whitespace mode (body: { diffType, base?, hideWhitespace? }) |
/api/file-content |
GET | Returns { oldContent, newContent } for expandable diff context (?path=&oldPath=&base=) |
/api/git-add |
POST | Stage/unstage a file (body: { filePath, undo? }) |
/api/feedback |
POST | Submit review (body: feedback, annotations, agentSwitch) |
/api/image |
GET | Serve image by path query param |
/api/upload |
POST | Upload image, returns { path, originalName } |
/api/draft |
GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
/api/editor-annotations |
GET | List editor annotations (VS Code only) |
/api/editor-annotation |
POST/DELETE | Add or remove an editor annotation (VS Code only) |
/api/ai/capabilities |
GET | Check if AI features are available |
/api/ai/session |
POST | Create or fork an AI session |
/api/ai/query |
POST | Send a message and stream the response (SSE) |
/api/ai/abort |
POST | Abort the current query |
/api/ai/permission |
POST | Respond to a permission request |
/api/ai/sessions |
GET | List active sessions |
/api/external-annotations/stream |
GET | SSE stream for real-time external annotations |
/api/external-annotations |
GET | Snapshot of external annotations (polling fallback, ?since=N for version gating) |
/api/external-annotations |
POST | Add external annotations (single or batch { annotations: [...] }) |
/api/external-annotations |
PATCH | Update fields on a single annotation (?id=) |
/api/external-annotations |
DELETE | Remove by ?id=, ?source=, or clear all |
/api/agents/capabilities |
GET | Check available agent providers (claude, codex, tour) |
/api/agents/jobs/stream |
GET | SSE stream for real-time agent job status updates |
/api/agents/jobs |
GET | Snapshot of agent jobs (polling fallback, ?since=N for version gating) |
/api/agents/jobs |
POST | Launch an agent job (body: { provider, command, label }) |
/api/agents/jobs |
DELETE | Kill all running agent jobs |
/api/agents/jobs/:id |
DELETE | Kill a specific agent job |
/api/pr-diff-scope |
POST | Switch between layer and full-stack diff scope |
/api/pr-list |
GET | List PRs for the current repo (cached 30s) |
/api/pr-switch |
POST | Switch to a different PR in-place (body: { url }) |
/api/tour/:jobId |
GET | Fetch Code Tour result (greeting, stops, checklist) for a completed tour job |
/api/tour/:jobId/checklist |
PUT | Persist checklist item state for a Code Tour |
Annotate Server (packages/server/annotate.ts)
| Endpoint | Method | Purpose |
|---|---|---|
/api/plan |
GET | Returns { plan, origin, mode: "annotate", filePath, sourceInfo?, gate } |
/api/feedback |
POST | Submit annotations (body: feedback, annotations) |
/api/approve |
POST | Approve without feedback (review-gate UX, --gate) |
/api/exit |
POST | Close session without feedback |
/api/image |
GET | Serve image by path query param |
/api/upload |
POST | Upload image, returns { path, originalName } |
/api/doc |
GET | Serve linked .md/.mdx/.html file or code file (?path=<path>&base=<dir>) |
/api/doc/exists |
POST | Batch-validate code-file paths (body: { paths: string[], base?: string }) |
/api/draft |
GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
/api/external-annotations/stream |
GET | SSE stream for real-time external annotations |
/api/external-annotations |
GET | Snapshot of external annotations (polling fallback, ?since=N for version gating) |
/api/external-annotations |
POST | Add external annotations (single or batch { annotations: [...] }) |
/api/external-annotations |
PATCH | Update fields on a single annotation (?id=) |
/api/external-annotations |
DELETE | Remove by ?id=, ?source=, or clear all |
All servers use random ports locally or fixed port (19432) in remote mode.
Paste Service (apps/paste-service/)
| Endpoint | Method | Purpose |
|---|---|---|
/api/paste |
POST | Store compressed plan data, returns { id } |
/api/paste/:id |
GET | Retrieve stored compressed data |
Runs as a separate service on port 19433 (self-hosted) or as a Cloudflare Worker (hosted).
Plan Version History
Every plan is automatically saved to ~/.plannotator/history/{project}/{slug}/ on arrival, before the user sees the UI. Versions are numbered sequentially (001.md, 002.md, etc.). The slug is derived from the plan's first # Heading + today's date via generateSlug(), scoped by project name (git repo or cwd). Same heading on the same day = same slug = same plan being iterated on. Identical resubmissions are deduplicated (no new file if content matches the latest version).
This powers the version history API (/api/plan/version, /api/plan/versions) and the plan diff system.
History saves independently of the planSave user setting (which controls decision snapshots in ~/.plannotator/plans/). Storage functions live in packages/shared/storage.ts (runtime-agnostic, re-exported by packages/server/storage.ts). Pi copies the shared files at build time. Slug format: {sanitized-heading}-YYYY-MM-DD (heading first for readability).
Plan Diff
When a user denies a plan and Claude resubmits, the UI shows what changed between versions. A +N/-M badge appears below the document card; clicking it toggles between normal view and diff view.
Diff engine (packages/ui/utils/planDiffEngine.ts): Uses the diff npm package (diffLines()) to compute line-level diffs. Groups consecutive remove+add into "modified" blocks. Returns PlanDiffBlock[] and PlanDiffStats.
Two view modes (toggle via PlanDiffModeSwitcher):
- Rendered (
PlanCleanDiffView): Color-coded left borders — green (added), red (removed/strikethrough), yellow (modified) - Raw (
PlanRawDiffView): Monospace+/-lines, git-style
State (packages/ui/hooks/usePlanDiff.ts): Manages base version selection, diff computation, and version fetching. The server sends previousPlan with the initial /api/plan response; the hook auto-diffs against it. Users can select any prior version from the sidebar Version Browser.
Diff annotations: The clean diff view supports block-level annotation — hover over added/removed/modified sections to annotate entire blocks. Annotations carry a diffContext field (added/removed/modified). Exported feedback includes [In diff content] labels.
Annotation hook (packages/ui/hooks/useAnnotationHighlighter.ts): Annotation infrastructure used by Viewer.tsx. Manages web-highlighter lifecycle, toolbar/popover state, annotation creation, text-based restoration, and scroll-to-selected. The diff view uses its own block-level hover system instead.
Sidebar (packages/ui/hooks/useSidebar.ts): Shared left sidebar with three tabs — Table of Contents, Version Browser, and Archive. The "Auto-open Sidebar" setting controls whether it opens on load (TOC tab only). In archive mode, the sidebar opens to the Archive tab automatically.
Data Types
Location: packages/ui/types.ts
enum AnnotationType {
DELETION = "DELETION",
COMMENT = "COMMENT",
GLOBAL_COMMENT = "GLOBAL_COMMENT",
}
interface ImageAttachment {
path: string; // temp file path
name: string; // human-readable label (e.g., "login-mockup")
}
interface Annotation {
id: string;
blockId: string;
startOffset: number;
endOffset: number;
type: AnnotationType;
text?: string; // For comment
originalText: string; // The selected text
createdA: number; // Timestamp
author?: string; // Tater identity
images?: ImageAttachment[]; // Attached images with names
source?: string; // External tool identifier (e.g., "eslint") — set when annotation comes from external API
diffContext?: 'added' | 'removed' | 'modified'; // Set when annotation created in plan diff view
startMeta?: { parentTagName; parentIndex; textOffset };
endMeta?: { parentTagName; parentIndex; textOffset };
}
interface Block {
id: string;
type: "paragraph" | "heading" | "blockquote" | "list-item" | "code" | "hr" | "table" | "html" | "directive";
content: string;
level?: number; // For headings (1-6)
language?: string; // For code blocks
alertKind?: "note" | "tip" | "warning" | "caution" | "important"; // GitHub alerts (blockquote subtype)
order: number;
startLine: number;
}
Markdown Parser
Location: packages/ui/utils/parser.ts
parseMarkdownToBlocks(markdown) splits markdown into Block objects. Handles:
- Headings (
#,##, etc.) with slug-derived anchor ids - Code blocks (``` with language extraction)
- List items (
-,*,1.) - Blockquotes (
>) — including GitHub alerts (> [!NOTE|TIP|WARNING|CAUTION|IMPORTANT]) which setalertKind - Horizontal rules (
---) - Tables (pipe-delimited) — rendered via
TableBlockwith aTableToolbar(copy as markdown/CSV) andTablePopoutoverlay - Raw HTML blocks (
<details>,<summary>, etc.) — rendered viaHtmlBlockthroughmarked+ DOMPurify - Directive containers (
:::kind ... :::) — rendered viaCallout - Paragraphs (default) with inline extras: bare URL autolinks,
@mentions/#issue-refs, emoji shortcodes, smart punctuation
exportAnnotations(blocks, annotations, globalAttachments) generates human-readable feedback for Claude. Images are referenced by name: [image-name] /tmp/path.... Annotations with diffContext include [In diff content] labels.
Annotation System
Selection mode: User selects text → toolbar appears → choose annotation type Redline mode: User selects text → auto-creates DELETION annotation
Text highlighting uses web-highlighter library. Code blocks use manual <mark> wrapping (web-highlighter can't select inside <pre>).
Keyboard Shortcuts
Location: packages/ui/shortcuts/ (engine + scope data), packages/editor/shortcuts.ts and packages/review-editor/shortcuts.ts (per-app surfaces).
The shortcut system has three layers:
- Engine (
packages/ui/shortcuts/{core,runtime}.ts) — parser for declarative bindings (Mod+Enter,Alt Altdouble-tap,Alt hold), dispatcher, platform-aware formatter (mac glyphs vs.Ctrl), validator, and theuseShortcutScope/useDoubleTapShortcutsReact hooks. Truly shared — both apps use it as-is. - Scopes —
defineShortcutScope({ id, title, shortcuts: { actionId: { bindings, description, section, ... } } }). One scope per UI surface (annotation toolbar, comment popover, file tree, etc.). Lives inpackages/ui/shortcuts/{plan-review,code-review}/— the subfolder names which app's UI the scope serves. Components/Apps wire handlers to a scope viauseShortcutScope({ scope, handlers: { actionId: () => ... } }). - Surfaces (
packages/editor/shortcuts.ts,packages/review-editor/shortcuts.ts) — each app composes its scopes into aShortcutSurface(planReviewSurface,annotateSurface,codeReviewSurface). Surfaces feed both the in-app help modal and the marketing site's auto-generated docs page.
Convention for adding new shortcuts: define the action in the relevant scope file under the right subfolder (plan-review/ or code-review/), declare the binding(s) and description, then wire a handler at the call site with useShortcutScope. The marketing docs page picks it up automatically at next build. Unit tests in packages/ui/shortcuts.test.ts enforce normalized binding tokens (Mod, Shift, Alt, A-Z, 1-0, named keys, F1–F12) and unique scope ids.
Marketing docs auto-generation: apps/marketing/src/lib/shortcutReference.ts reads the three surfaces and apps/marketing/src/components/ShortcutReference.astro renders them as tables. The /docs/reference/keyboard-shortcuts page is special-cased in apps/marketing/src/pages/docs/[...slug].astro to render the component instead of the markdown body.
URL Sharing
Location: packages/ui/utils/sharing.ts, packages/ui/hooks/useSharing.ts
Shares full plan + annotations via URL hash using deflate compression. For large plans, short URLs are created via the paste service (user must explicitly confirm).
Payload format:
// Image in shareable format: plain string (old) or [path, name] tuple (new)
type ShareableImage = string | [string, string];
interface SharePayload {
p: string; // Plan markdown
a: ShareableAnnotation[]; // Compact annotations
g?: ShareableImage[]; // Global attachments
d?: (string | null)[]; // diffContext per annotation, parallel to `a`
}
type ShareableAnnotation =
| ["D", string, string | null, ShareableImage[]?] // [type, original, author, images?]
| ["C", string, string, string | null, ShareableImage[]?] // [type, original, comment, author, images?]
| ["G", string, string | null, ShareableImage[]?]; // [type, comment, author, images?]
Compression pipeline:
JSON.stringify(payload)CompressionStream('deflate-raw')- Base64 encode
- URL-safe: replace
+/=with-_
On load from shared URL:
- Parse hash, decompress, restore annotations
- Find text positions in rendered DOM via text search
- Apply
<mark>highlights - Clear hash from URL (prevents re-parse on refresh)
Settings Persistence
Location: packages/ui/utils/storage.ts, planSave.ts, agentSwitch.ts
Uses cookies (not localStorage) because each hook invocation runs on a random port. Settings include identity, plan saving (enabled/custom path), and agent switching (OpenCode only).
Syntax Highlighting
Code blocks use bundled highlight.js. Language is extracted from fence (```rust) and applied as language-{lang}class. Each block highlighted individually viahljs.highlightElement().
Requirements
- Bun runtime
- Claude Code with plugin/hooks support, or OpenCode
- Cross-platform: macOS (
open), Linux (xdg-open), Windows (start)
Development
bun install
# Run any app
bun run dev:hook # Hook server (plan review)
bun run dev:review # Review editor (code review)
bun run dev:portal # Portal editor
bun run dev:marketing # Marketing site
bun run dev:vscode # VS Code extension (watch mode)
Build
bun run build:hook # Single-file HTML for hook server
bun run build:review # Code review editor
bun run build:opencode # OpenCode plugin (copies HTML from hook + review)
bun run build:portal # Static build for share.plannotator.ai
bun run build:marketing # Static build for plannotator.ai
bun run build:vscode # VS Code extension bundle
bun run package:vscode # Package .vsix for marketplace
bun run build # Build hook + opencode (main targets)
Important: Build order matters. The hook build (build:hook) copies pre-built HTML from apps/review/dist/. If you change UI code in packages/ui/, packages/editor/, or packages/review-editor/, you must rebuild the review app first, then the hook:
bun run --cwd apps/review build && bun run build:hook # For review UI changes
bun run build:hook # For plan UI changes only
bun run build:hook && bun run build:opencode # For OpenCode plugin
Running only build:hook after review-editor changes will copy stale HTML files. When testing locally with a compiled binary, the full sequence is:
bun run --cwd apps/review build && bun run build:hook && \
bun build apps/hook/server/index.ts --compile --outfile ~/.local/bin/plannotator
Running only build:opencode will copy stale HTML files.
Marketing Site
apps/marketing/ is the plannotator.ai website — landing page, documentation, and blog. Built with Astro 5 (static output, zero client JS except a theme toggle island). Docs are markdown files in src/content/docs/, blog posts in src/content/blog/, both using Astro content collections. Tailwind CSS v4 via @tailwindcss/vite. Deploys to S3/CloudFront via GitHub Actions on push to main.
The /docs/reference/keyboard-shortcuts page is auto-generated from the shortcut registry at build time — see the Keyboard Shortcuts section above. Editing the markdown body has no effect; update the scope files instead.
Test plugin locally
claude --plugin-dir ./apps/hook