Files
backnotprop__plannotator/CLAUDE.md
T
Michael Ramos 4b097197a0 feat(ai): AI backbone + inline chat for code review (#363)
* 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.
2026-03-23 12:13:03 -07:00

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:

  1. JSON.stringify(payload)
  2. CompressionStream('deflate-raw')
  3. Base64 encode
  4. URL-safe: replace +/= with -_

On load from shared URL:

  1. Parse hash, decompress, restore annotations
  2. Find text positions in rendered DOM via text search
  3. Apply <mark> highlights
  4. 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