* feat(ai): add provider-agnostic AI backbone for inline chat Introduces @plannotator/ai — a pluggable provider layer that powers AI features (inline chat, plan Q&A, code review assistance) across all Plannotator surfaces. - ProviderRegistry: class-based, multi-instance provider management - SessionManager: in-memory session tracking with eviction, ID aliasing - Context builders: system prompts and fork preambles for all three modes - SSE streaming endpoints: session create/fork, query, abort, list - Claude Agent SDK provider: fork from parent sessions, read-only tools, per-query abort, cached SDK import, concurrent query guard - 34 passing tests covering all layers For provenance purposes, this commit was AI assisted. * fix(ai): address code review findings from PR #363 - Concurrent query race: reject with session_busy error instead of racing; add query generation counter so stale finally blocks don't clobber newer query state - Text dropped before tool_use: mapSDKMessage now returns AIMessage[] and flushes accumulated text before each tool_use block - defaultProvider returns instance ID: ProviderRegistry.getDefault() returns { id, provider } so capabilities endpoint sends the correct key for multi-instance setups - Fork preamble re-sent on error: use _firstQuerySent boolean instead of _resolvedId to guard preamble injection - Dead abortController params: remove unused abortController from CreateSessionOptions and AIProvider.resumeSession - Unused context in memory: remove context from SessionConfig and baseConfig — already consumed to build systemPrompt/forkPreamble - Fix tool_result mapping: SDK wraps tool results in SDKUserMessage (type "user"), not a "tool_result" type For provenance purposes, this commit was AI assisted. * feat(ai): inline AI chat on code review diffs Add "Ask AI" to the code review toolbar — select lines, ask questions about agent-written code, get streaming responses. Conversation persists across file switches with inline markers on the diff showing where questions were asked. - useAIChat hook: SSE streaming, lazy session creation, multi-turn - AskAIInput: question input with per-selection AI history - InlineAIMarker: compact sparkle markers on diff lines via @pierre - AITab: sidebar conversation grouped by file with collapsible sections - SparklesIcon: shared 3-star sparkle from automations branch - ReviewPanel: AI tab with message count badge, file group flash highlight - Server: mount AI endpoints in review server with graceful fallback - Support three question scopes: line, file, and general - Toolbar ergonomics: typed text submits as AI question, no double popover For provenance purposes, this commit was AI assisted. * fix(ai): toolbar popover ergonomics and visual consistency - X button closes entire toolbar in both modes (no ghost popover) - Comment/Ask AI toggle is inline in the action row, same position in both modes (left side, mirrored layout) - AI history appears below the input, not above - File group flash highlight uses --muted to match file tree hover For provenance purposes, this commit was AI assisted. * fix(ai): visual consistency, shared primitives, markdown rendering Visual consistency: - AI tab file group headers match FileTree pattern (sizing, hover, badges) - Q&A bubbles match annotation card padding and border-radius - Empty state uses circle-bg icon (same as annotations) - General input matches search input styling - Removed redundant CSS classes, moved to Tailwind Shared primitives: - Extract formatTimestamp to shared utility (was duplicated) - CountBadge component for consistent badge styling - CopyButton component with hover-reveal + "Copied" flash Affordances: - Copy button on AI responses (hover-reveal) - Send button on general input with keyboard shortcut hint - Timestamps on Q&A pairs - Keyboard shortcut title on Ask button Markdown rendering: - Add marked dependency for full block-level markdown in AI responses - renderMarkdown utility using marked + DOMPurify - Scoped .ai-markdown CSS for headings, lists, code blocks, blockquotes For provenance purposes, this commit was AI assisted. * feat(ai): permission inheritance + inline approval UI The inline AI agent now inherits the user's existing Claude Code permission rules via settingSources: ['user', 'project']. Tools already approved in ~/.claude/settings.json auto-approve silently. For tools not covered by existing rules, the SDK emits control_request messages. These are mapped to a new AIPermissionRequestMessage type and surfaced as inline approval cards in the AI tab — showing the tool name, command/input, and Allow/Deny buttons. Backend: - Map control_request to AIPermissionRequestMessage in provider - Store Query object on session for sending control_response back - respondToPermission() method on AISession sends allow/deny - POST /api/ai/permission endpoint for frontend → backend decisions - Add settingSources to ClaudeSDKQueryOptions Frontend: - PermissionCard component with Allow/Deny buttons + resolved state - useAIChat tracks permissionRequests array - respondToPermission() POSTs decision to server - AITab renders pending permission cards inline in conversation For provenance purposes, this commit was AI assisted. * fix(ai): streaming reactivity, maxTurns, popover→sidebar linking Streaming fixes: - State updates now create new object references via prev.map() instead of mutating objects inside the array — fixes React not re-rendering the sidebar during streaming - Complete assistant text messages only used as fallback when no streaming deltas received (prevents duplicate text) - Result text used as fallback when response is empty (agent spent all turns on tools) Config: - maxTurns raised to 99 (was 3 — agent ran out of turns reading files and never got to write its answer) Popover → sidebar linking: - Clicking AI history items in comment popover now passes questionId through to handleViewAIResponse, triggering the file group flash highlight in the sidebar (same behavior as clicking inline markers) Debug logging (temporary): - Server-side: [AI STREAM] logs every message the provider yields - Client-side: [AI SSE] logs every message received in the browser For provenance purposes, this commit was AI assisted. * refactor(ai): simplification pass — types, perf, cleanup From the three-agent code review analysis: Quality: - Rename AIMessage → AIChatEntry in review-editor to avoid collision with the wire-protocol AIMessage from packages/ai - Type permissionMode as union (not string) in SessionConfig + QueryOptions - Type _activeQuery with narrow interface instead of any - Make toolUseId optional on AIToolResultMessage (SDK doesn't populate it) - Remove redundant sessionIdRef double-write in useAIChat - Fix handleAskGeneral useCallback dep: [aiChat] → [aiChat.ask] Performance: - Wrap QAPair in React.memo to prevent re-renders on sibling updates - Memoize renderMarkdown output via useMemo (avoid re-parsing on every token) - Fix auto-scroll useEffect: depend on messages.length not messages ref (prevents querySelectorAll on every streaming token) Cleanup: - Remove debug console.log from SSE endpoint and browser parser - Extract isMac/submitHint to shared utils/platform.ts - Extract generateId to shared utils/generateId.ts Net: -2 lines. No behavior changes. For provenance purposes, this commit was AI assisted. * chore: biome lint fixes — import types, button a11y, unused import - Use `import type` where only types are consumed (endpoints, CountBadge, CopyButton, renderMarkdown) - Remove unused AIContext import from session-manager - Template literal instead of string concat in context.ts truncate() - Add type="button" + aria-hidden on CopyButton SVGs For provenance purposes, this commit was AI assisted. * feat(ai): Codex SDK provider + multi-provider backend - Add CodexSDKProvider implementing AIProvider interface - Maps Codex ThreadEvents to AIMessage types - Delta tracking for streaming text from cumulative item.updated events - Supports resume, abort, sandbox modes - Factory self-registration pattern matching Claude provider - Enhance capabilities endpoint with provider models + metadata - Register both providers in review server (graceful degradation) - Add CodexSDKConfig type, models list (GPT-5.4 family) - Add reasoningEffort to CreateSessionOptions (Codex only) - Extract DiffFile interface to shared types.ts - Update CLAUDE.md with build order documentation - 54 tests passing (20+ new for Codex event mapping) For provenance purposes, this commit was AI assisted. * feat(ai): provider settings UI + inline config bar - Settings → AI tab: provider cards with per-provider model dropdowns - Inline config bar in AI chat sidebar (provider, model, reasoning effort) - Provider icons: Claude SVG + Codex PNG from marketing assets - Cookie-based persistence for provider + per-provider model preferences - useAIChat accepts providerId, model, reasoningEffort from props - Session reset on provider/model switch (next question starts fresh) - Reasoning effort selector (Codex only: Low/Medium/High/Max) - Update code review docs with AI provider documentation For provenance purposes, this commit was AI assisted. * fix(ai): review fixes — reasoning props, input blocking, cwd resolution, codex-sdk dep - Add missing selectedReasoningEffort + onReasoningEffortChange props to populated-state AIConfigBar (was only on empty-state, causing crash) - Disable GeneralInput while streaming to prevent concurrent queries - Thread cwd through CreateSessionOptions so AI sessions use the correct working directory (worktree-aware via getCwd resolver) - Add @openai/codex-sdk as a real dependency (was only an optional peer) - Eagerly verify codex-sdk importability at startup to avoid advertising a broken provider For provenance purposes, this commit was AI assisted. * fix(ai): polish config bar, input, and sidebar header - Replace native <select> dropdowns with custom popover menus that show provider icons, model names, and checkmarks on active selection - Remove disabled gear placeholder button - Auto-grow textarea upward on multi-line input (capped at 6 lines) - Push tab icons to far right in sidebar header (w-full on flex container) - Add centered divider between file-scoped and general questions For provenance purposes, this commit was AI assisted. * fix(ai): honest fork capability + permissionMode doc accuracy - Endpoint now checks capabilities.fork before calling forkSession(), falling back to createSession() for providers that can't truly fork - Codex forkSession() now throws as the interface contract requires (was silently faking a fork with no history — misleading UX) - Fixed permissionMode JSDoc: said "plan" but code defaults to "default" For provenance purposes, this commit was AI assisted. * fix(review): move search result count inside input field Count now appears inline to the left of the X button instead of squeezing the input from outside. Dynamic right padding accommodates the count + clear button when a query is active. For provenance purposes, this commit was AI assisted. * fix(ai): use claude_code preset, pass selected code to queries - Claude provider now uses { preset: "claude_code", append } instead of a raw system prompt, so inline chat inherits Claude Code's full built-in capabilities - Stripped custom role/guidelines preamble from context builders — just emit the review context (diff, plan, document) - handleAskAI now extracts selected code via extractLinesFromPatch and sends it with the query, fixing hallucinations on large PRs where the file's diff was truncated out of the system prompt For provenance purposes, this commit was AI assisted. * fix(ai): bundle both SDKs, remove peer dependencies Both @anthropic-ai/claude-agent-sdk and @openai/codex-sdk are now direct imports (no string indirection) so bun build --compile bundles them into the binary. Users don't need to install SDKs separately. Removed peer dependency declarations from packages/ai/package.json — meaningless for a compiled binary. For provenance purposes, this commit was AI assisted. * refactor: canonical platform.ts, file renames, file-level comments - Created packages/ui/utils/platform.ts as the single source for isMac, modKey, altKey, submitHint, isWindows - Consolidated inline navigator.platform checks from 6 files - Renamed renderMarkdown → renderChatMarkdown (distinct from plan editor) - Renamed formatTimestamp → formatRelativeTime (clearer purpose) - Added file-level comments to InlineAIMarker, PermissionCard, generateId For provenance purposes, this commit was AI assisted. * docs(ai): add AI features guide + link from settings New docs page at guides/ai-features covering providers, models, configuration, session behavior, permissions, and available settings. AISettingsTab now links to the docs in both empty and populated states. For provenance purposes, this commit was AI assisted.
22 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.
│ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views
│ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser
│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.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.tsx
│ └── review-editor/ # Code review UI
│ ├── App.tsx # Main review app
│ ├── components/ # DiffViewer, FileTree, ReviewPanel
│ ├── 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)
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 or true for remote mode (devcontainer, SSH). Uses fixed port and skips browser open. |
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. |
Legacy: SSH_TTY and SSH_CONNECTION are still detected. Prefer PLANNOTATOR_REMOTE=1 for explicit control.
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> command
↓
Claude Code: plannotator annotate subcommand runs
OpenCode: event handler intercepts command
↓
Markdown file read from disk
↓
Annotate server starts (reuses plan editor HTML with mode:"annotate")
↓
User annotates markdown, 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/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) |
Review Server (packages/server/review.ts)
| Endpoint | Method | Purpose |
|---|---|---|
/api/diff |
GET | Returns { rawPatch, gitRef, origin, diffType, gitContext } |
/api/file-content |
GET | Returns { oldContent, newContent } for expandable diff context |
/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 |
Annotate Server (packages/server/annotate.ts)
| Endpoint | Method | Purpose |
|---|---|---|
/api/plan |
GET | Returns { plan, origin, mode: "annotate", filePath } |
/api/feedback |
POST | Submit annotations (body: feedback, annotations) |
/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 |
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",
INSERTION = "INSERTION",
REPLACEMENT = "REPLACEMENT",
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/replacement/insertion
originalText: string; // The selected text
createdA: number; // Timestamp
author?: string; // Tater identity
images?: ImageAttachment[]; // Attached images with names
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";
content: string;
level?: number; // For headings (1-6)
language?: string; // For code blocks
order: number;
startLine: number;
}
Markdown Parser
Location: packages/ui/utils/parser.ts
parseMarkdownToBlocks(markdown) splits markdown into Block objects. Handles:
- Headings (
#,##, etc.) - Code blocks (``` with language extraction)
- List items (
-,*,1.) - Blockquotes (
>) - Horizontal rules (
---) - Paragraphs (default)
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>).
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?]
| ["R", string, string, string | null, ShareableImage[]?] // [type, original, replacement, author, images?]
| ["C", string, string, string | null, ShareableImage[]?] // [type, original, comment, author, images?]
| ["I", string, string, string | null, ShareableImage[]?] // [type, context, newText, 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.
Test plugin locally
claude --plugin-dir ./apps/hook