From de925ded14ad1b64d726425399ebd3bd8a141747 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 5 May 2026 12:37:12 +0700 Subject: [PATCH 01/39] Preserve truthful approval semantics for Claude plan bypass Thread a clear-context reminder flag through approval decisions, expose a Claude Code-only approval entry that requests bypass mode, and keep the hook response honest by emitting a reminder instead of claiming context was cleared. Constraint: Claude Code PermissionRequest hooks have no documented clearContext response field and bypassPermissions is only a request when the mode is available. Rejected: adding a permission-mode enum or undocumented clearContext field | those would misrepresent hook capabilities and broaden the contract. Confidence: high Scope-risk: moderate Directive: Do not claim Plannotator clears context until Claude Code documents a hook field for that behavior; keep reminder copy truthful. Tested: bun test packages/server apps/hook; bun test packages/editor/wideMode.test.ts packages/ui/hooks/useAgentSettings.test.ts; bun test; bun run build:review; bun run build:hook; bun run typecheck; git diff --check --cached. Not-tested: Browser warning-dialog replay and interactive Claude Code smoke test. Co-authored-by: OmX --- apps/hook/server/index.ts | 4 + packages/server/index.ts | 12 +- packages/ui/components/ApproveDropdown.tsx | 149 ++++++++++++++------- 3 files changed, 119 insertions(+), 46 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 9287d2fee..b1e687974 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1277,6 +1277,10 @@ if (args[0] === "sessions") { console.log( JSON.stringify({ + ...(result.clearContextNudge && { + systemMessage: + "Plannotator requested bypass mode. Hooks cannot clear context. Run /clear before continuing if you want a fresh implementation session.", + }), hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { diff --git a/packages/server/index.ts b/packages/server/index.ts index 8b1356a49..c71677346 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -101,6 +101,7 @@ export interface ServerResult { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -172,6 +173,7 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -179,6 +181,7 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; }>; if (mode !== "archive") { @@ -455,6 +458,7 @@ export async function startPlannotatorServer( let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; + let clearContextNudge: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -466,6 +470,7 @@ export async function startPlannotatorServer( agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; + clearContextNudge?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -483,6 +488,11 @@ export async function startPlannotatorServer( requestedPermissionMode = body.permissionMode; } + // Capture optional /clear reminder request for Claude Code approval flow + if (body.clearContextNudge === true) { + clearContextNudge = true; + } + // Capture plan save settings if (body.planSave !== undefined) { planSaveEnabled = body.planSave.enabled; @@ -528,7 +538,7 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge }); return Response.json({ ok: true, savedPath }); } diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index ab9c48c7f..b669f76e2 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -2,11 +2,21 @@ import React, { useState, useRef, useEffect } from 'react'; import type { Agent } from '../hooks/useAgents'; import { getAgentSwitchSettings, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch'; +export interface ApproveExtraEntry { + id: string; + label: string; + description?: string; + onSelect: () => void; + disabled?: boolean; +} + interface ApproveDropdownProps { onApprove: () => void; agents: Agent[]; disabled?: boolean; isLoading?: boolean; + extraEntries?: ApproveExtraEntry[]; + showAgentSwitch?: boolean; } function getSelectedLabel(setting: AgentSwitchSettings, agents: Agent[]): string | null { @@ -35,6 +45,8 @@ export const ApproveDropdown: React.FC = ({ agents, disabled = false, isLoading = false, + extraEntries = [], + showAgentSwitch, }) => { const [setting, setSetting] = useState(() => getAgentSwitchSettings()); const [isOpen, setIsOpen] = useState(false); @@ -57,6 +69,10 @@ export const ApproveDropdown: React.FC = ({ }; }, []); + const hasExtraEntries = extraEntries.length > 0; + const shouldShowAgentSwitch = showAgentSwitch ?? agents.length > 0; + const hasDropdownContent = hasExtraEntries || shouldShowAgentSwitch; + const handleSelect = (newSetting: AgentSwitchSettings) => { setSetting(newSetting); saveAgentSwitchSettings(newSetting); @@ -78,16 +94,36 @@ export const ApproveDropdown: React.FC = ({ onApprove(); }; + const handleExtraSelect = (entry: ApproveExtraEntry) => { + if (entry.disabled) return; + setIsOpen(false); + entry.onSelect(); + }; + return (
- {/* Mobile: simple button */} - + {/* Mobile: simple button, with menu when extra actions exist */} +
+ + {hasDropdownContent && ( + + )} +
{/* Desktop: split button */}
@@ -109,8 +145,9 @@ export const ApproveDropdown: React.FC = ({
{/* Dropdown */} - {isOpen && ( -
-
- Switch to agent -
- {agents.map((agent) => { - const selected = isSelected(agent.id, setting); - return ( + {isOpen && hasDropdownContent && ( +
+ {hasExtraEntries && ( + <> + {extraEntries.map((entry) => ( + + ))} + {shouldShowAgentSwitch &&
} + + )} + {shouldShowAgentSwitch && ( + <> +
+ Switch to agent +
+ {agents.map((agent) => { + const selected = isSelected(agent.id, setting); + return ( + + ); + })} + {isCustom && setting.customName && ( + + )} +
- ); - })} - {isCustom && setting.customName && ( - + )} -
-
)}
From c92abe2c290761a0153ee8d92049747264a71eb3 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 5 May 2026 15:05:47 +0700 Subject: [PATCH 02/39] Keep approval payloads truthful across Claude Code and Pi Constraint: Claude Code hooks cannot clear context directly; the new action remains a truthful reminder plus bypass-mode approval. Rejected: Sending OpenCode agent-switch state for Claude Code | it leaked build (?) UI state and misleading payload fields. Confidence: high Scope-risk: narrow Directive: Keep OpenCode agent switching gated to opencode-origin approval payloads. Tested: bun run typecheck; bun test; bun run build:review; bun run build:hook Not-tested: Manual browser click-through of the Claude Code dropdown. --- apps/pi-extension/plannotator-browser.ts | 1 + apps/pi-extension/plannotator-events.ts | 2 + apps/pi-extension/server.test.ts | 40 ++++++++++++++++ apps/pi-extension/server/serverPlan.ts | 4 ++ packages/editor/App.tsx | 33 +++++++------ packages/editor/approvalBody.test.ts | 33 +++++++++++++ packages/editor/approvalBody.ts | 47 +++++++++++++++++++ .../ui/components/ApproveDropdown.test.tsx | 24 ++++++++++ packages/ui/components/ApproveDropdown.tsx | 8 ++-- 9 files changed, 171 insertions(+), 21 deletions(-) create mode 100644 packages/editor/approvalBody.test.ts create mode 100644 packages/editor/approvalBody.ts create mode 100644 packages/ui/components/ApproveDropdown.test.tsx diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index c1d390edc..99fdc52b7 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -35,6 +35,7 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface BrowserDecisionSession { diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index 12845ae70..f2754a6de 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -73,6 +73,7 @@ export interface PlannotatorReviewResultEvent { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlannotatorReviewStatusPayload { @@ -248,6 +249,7 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { savedPath: result.savedPath, agentSwitch: result.agentSwitch, permissionMode: result.permissionMode, + clearContextNudge: result.clearContextNudge, } satisfies PlannotatorReviewResultEvent; setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult }); pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index bbfafaff9..a0dc5215d 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -9,6 +9,7 @@ import { getVcsContext, prepareLocalReviewDiff, runGitDiff, + startPlanReviewServer, startReviewServer, } from "./server"; @@ -134,6 +135,45 @@ afterEach(() => { describe("pi review server", () => { const testIfJj = hasJj() ? test : test.skip; + test("plan approve preserves clear context nudge decisions", async () => { + const homeDir = makeTempDir("plannotator-pi-home-"); + const repoDir = makeTempDir("plannotator-pi-plan-"); + process.env.HOME = homeDir; + process.chdir(repoDir); + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + const server = await startPlanReviewServer({ + plan: "# Plan\n\nShip it.", + htmlContent: "plan", + origin: "pi", + permissionMode: "acceptEdits", + }); + + try { + const approveResponse = await fetch(`${server.url}/api/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + permissionMode: "bypassPermissions", + clearContextNudge: true, + planSave: { enabled: false }, + }), + }); + expect(approveResponse.status).toBe(200); + + await expect(server.waitForDecision()).resolves.toEqual({ + approved: true, + feedback: undefined, + savedPath: undefined, + agentSwitch: undefined, + permissionMode: "bypassPermissions", + clearContextNudge: true, + }); + } finally { + server.stop(); + } + }); + test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = initRepo(); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 06ba52754..9df014487 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -56,6 +56,7 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlanServerResult { @@ -369,6 +370,7 @@ export async function startPlanReviewServer(options: { let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; + let clearContextNudge: boolean | undefined; let planSaveEnabled = true; let planSaveCustomPath: string | undefined; try { @@ -377,6 +379,7 @@ export async function startPlanReviewServer(options: { if (body.agentSwitch) agentSwitch = body.agentSwitch as string; if (body.permissionMode) requestedPermissionMode = body.permissionMode as string; + if (body.clearContextNudge === true) clearContextNudge = true; if (body.planSave !== undefined) { const ps = body.planSave as { enabled: boolean; customPath?: string }; planSaveEnabled = ps.enabled; @@ -438,6 +441,7 @@ export async function startPlanReviewServer(options: { savedPath, agentSwitch, permissionMode: effectivePermissionMode, + clearContextNudge, }); json(res, { ok: true, savedPath }); } else if (url.pathname === "/api/deny" && req.method === "POST") { diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index c48b7eb2f..4f2fc2e1a 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -75,6 +75,7 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; +import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -952,24 +953,22 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - // Build request body - include integrations if enabled - const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {}; - - // Include permission mode for Claude Code - if (origin === 'claude-code') { - body.permissionMode = permissionMode; - } - const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); - if (effectiveAgent) { - body.agentSwitch = effectiveAgent; - } - - // Include plan save settings - body.planSave = { - enabled: planSaveSettings.enabled, - ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), - }; + const body = buildApprovalRequestBody({ + origin, + permissionMode, + override, + effectiveAgent, + planSaveSettings, + }); + const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); + const body = buildApprovalRequestBody({ + origin, + permissionMode, + override, + effectiveAgent, + planSaveSettings, + }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); if (obsidianSettings.enabled && effectiveVaultPath) { diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts new file mode 100644 index 000000000..c1f024e74 --- /dev/null +++ b/packages/editor/approvalBody.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test'; +import { buildApprovalRequestBody } from './approvalBody'; + +describe('buildApprovalRequestBody', () => { + test('omits agentSwitch for Claude Code approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + override: { + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('keeps agentSwitch for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); +}); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts new file mode 100644 index 000000000..7ebb4b834 --- /dev/null +++ b/packages/editor/approvalBody.ts @@ -0,0 +1,47 @@ +import type { Origin } from '@plannotator/shared/agents'; +import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; + +export type ApprovalOverride = { + permissionMode?: PermissionMode; + clearContextNudge?: boolean; +}; + +export interface ApprovalRequestBody { + obsidian?: object; + bear?: object; + octarine?: object; + feedback?: string; + agentSwitch?: string; + planSave?: { enabled: boolean; customPath?: string }; + permissionMode?: string; + clearContextNudge?: boolean; +} + +export function buildApprovalRequestBody(options: { + origin: Origin | null; + permissionMode: PermissionMode; + override?: ApprovalOverride; + effectiveAgent?: string; + planSaveSettings: { enabled: boolean; customPath?: string | null }; +}): ApprovalRequestBody { + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; + const body: ApprovalRequestBody = {}; + + if (origin === 'claude-code') { + body.permissionMode = override.permissionMode ?? permissionMode; + if (override.clearContextNudge) { + body.clearContextNudge = true; + } + } + + if (origin === 'opencode' && effectiveAgent) { + body.agentSwitch = effectiveAgent; + } + + body.planSave = { + enabled: planSaveSettings.enabled, + ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), + }; + + return body; +} diff --git a/packages/ui/components/ApproveDropdown.test.tsx b/packages/ui/components/ApproveDropdown.test.tsx new file mode 100644 index 000000000..967f8e4be --- /dev/null +++ b/packages/ui/components/ApproveDropdown.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { describe, expect, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ApproveDropdown } from './ApproveDropdown'; + +describe('ApproveDropdown', () => { + test('does not show agent-switch label when only extra approval entries are enabled', () => { + const html = renderToStaticMarkup( + {}} + agents={[]} + extraEntries={[{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + onSelect: () => {}, + }]} + />, + ); + + expect(html).toContain('Approve'); + expect(html).not.toContain('build'); + expect(html).not.toContain('(?)'); + }); +}); diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index b669f76e2..9424696d3 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -79,10 +79,10 @@ export const ApproveDropdown: React.FC = ({ setIsOpen(false); }; - const agentLabel = getSelectedLabel(setting, agents); - const isNoSwitch = setting.switchTo === 'disabled'; - const isCustom = setting.switchTo === 'custom'; - const notFound = agentLabel && !isNoSwitch && !isCustom + const agentLabel = shouldShowAgentSwitch ? getSelectedLabel(setting, agents) : null; + const isNoSwitch = shouldShowAgentSwitch && setting.switchTo === 'disabled'; + const isCustom = shouldShowAgentSwitch && setting.switchTo === 'custom'; + const notFound = shouldShowAgentSwitch && agentLabel && !isNoSwitch && !isCustom && !agents.some(a => a.id.toLowerCase() === setting.switchTo.toLowerCase()); const baseClasses = disabled From 566173160b96f0002093461c314677c870a7632a Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 7 May 2026 02:20:25 +0700 Subject: [PATCH 03/39] Keep Plannotator approvals actionable for bypass clear context Route Claude Code plan approvals through explicit bypass payloads, consent-gated native clear-context deferral, and a fallback /clear nudge so selecting the clear-context mode no longer collapses into a silent approval no-op. The active ignored hook bundle was rebuilt locally after this source change. Constraint: Claude Code hooks cannot directly clear context; native clear requires showClearContextOnPlanAccept and user consent.\nRejected: Treating bypassPermissionsClearReminder as a raw permissionMode | Claude Code only accepts bypassPermissions on the wire and would ignore the local UI-only value.\nConfidence: high\nScope-risk: moderate\nDirective: Rebuild apps/hook/dist/index.html after changing plan-review UI because the local plannotator launcher imports the ignored dist bundle at runtime.\nTested: git diff --check; bun run typecheck; bun test; bun run build:review; bun run build:hook; fixed-port hook smoke for /api/settings-status and native-clear /api/approve.\nNot-tested: Manual click-through in Claude Code native plan-accept dialog. --- apps/hook/server/clearContextSetting.test.ts | 146 +++++++++++++++++++ apps/hook/server/clearContextSetting.ts | 105 +++++++++++++ apps/hook/server/index.ts | 21 +++ apps/pi-extension/server.test.ts | 33 +++++ apps/pi-extension/server/serverPlan.ts | 10 ++ packages/editor/App.tsx | 133 +++++++++++++++-- packages/editor/approvalBody.test.ts | 113 ++++++++++++++ packages/editor/approvalBody.ts | 16 +- packages/server/index.ts | 104 ++++++++++++- packages/ui/components/Settings.tsx | 4 +- packages/ui/utils/permissionMode.ts | 16 +- 11 files changed, 678 insertions(+), 23 deletions(-) create mode 100644 apps/hook/server/clearContextSetting.test.ts create mode 100644 apps/hook/server/clearContextSetting.ts diff --git a/apps/hook/server/clearContextSetting.test.ts b/apps/hook/server/clearContextSetting.test.ts new file mode 100644 index 000000000..6e427893b --- /dev/null +++ b/apps/hook/server/clearContextSetting.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +let tmpHome: string; + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "plannotator-clear-context-test-")); + mock.module("os", () => { + const realOs = require("node:os"); + return { ...realOs, homedir: () => tmpHome }; + }); +}); + +afterEach(() => { + mock.restore(); + rmSync(tmpHome, { recursive: true, force: true }); +}); + +async function freshImport() { + return (await import( + `./clearContextSetting?t=${Date.now()}-${Math.random()}` + )) as typeof import("./clearContextSetting"); +} + +function writeConsent() { + mkdirSync(join(tmpHome, ".plannotator", "consent"), { recursive: true }); + writeFileSync( + join(tmpHome, ".plannotator", "consent", "clear-context-setting.json"), + JSON.stringify({ consented: true }), + "utf8", + ); +} + +describe("clearContextSetting", () => { + test("does not create settings without consent", async () => { + const { ensureClearContextSettingEnabled } = await freshImport(); + await ensureClearContextSettingEnabled(); + expect(existsSync(join(tmpHome, ".claude", "settings.json"))).toBe(false); + }); + + test("creates settings with showClearContextOnPlanAccept when consent exists", async () => { + writeConsent(); + const { ensureClearContextSettingEnabled } = await freshImport(); + await ensureClearContextSettingEnabled(); + + const settings = JSON.parse( + readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8"), + ); + expect(settings.showClearContextOnPlanAccept).toBe(true); + }); + + test("preserves existing settings keys", async () => { + writeConsent(); + mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + writeFileSync( + join(tmpHome, ".claude", "settings.json"), + JSON.stringify({ theme: "dark", env: { A: "B" } }), + "utf8", + ); + + const { ensureClearContextSettingEnabled } = await freshImport(); + await ensureClearContextSettingEnabled(); + + const settings = JSON.parse( + readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8"), + ); + expect(settings.theme).toBe("dark"); + expect(settings.env).toEqual({ A: "B" }); + expect(settings.showClearContextOnPlanAccept).toBe(true); + }); + + test("is idempotent when setting is already enabled", async () => { + writeConsent(); + mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + writeFileSync( + join(tmpHome, ".claude", "settings.json"), + JSON.stringify({ showClearContextOnPlanAccept: true }), + "utf8", + ); + + const { ensureClearContextSettingEnabled } = await freshImport(); + await ensureClearContextSettingEnabled(); + await ensureClearContextSettingEnabled(); + + const settings = JSON.parse( + readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8"), + ); + expect(settings.showClearContextOnPlanAccept).toBe(true); + }); + + test("leaves malformed settings JSON untouched", async () => { + writeConsent(); + mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + const malformed = "{ this is not valid json"; + writeFileSync(join(tmpHome, ".claude", "settings.json"), malformed, "utf8"); + + const { ensureClearContextSettingEnabled } = await freshImport(); + await ensureClearContextSettingEnabled(); + + expect(readFileSync(join(tmpHome, ".claude", "settings.json"), "utf8")).toBe( + malformed, + ); + }); + + test("records consent atomically", async () => { + const { recordConsent } = await freshImport(); + recordConsent(); + + const consentPath = join( + tmpHome, + ".plannotator", + "consent", + "clear-context-setting.json", + ); + expect(existsSync(consentPath)).toBe(true); + const consent = JSON.parse(readFileSync(consentPath, "utf8")); + expect(consent.consented).toBe(true); + expect(typeof consent.recordedAt).toBe("string"); + }); + + test("reports disabled when settings are missing", async () => { + const { isClearContextSettingEnabled } = await freshImport(); + expect(isClearContextSettingEnabled()).toBe(false); + }); + + test("reports enabled when setting is true", async () => { + mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + writeFileSync( + join(tmpHome, ".claude", "settings.json"), + JSON.stringify({ showClearContextOnPlanAccept: true }), + "utf8", + ); + + const { isClearContextSettingEnabled } = await freshImport(); + expect(isClearContextSettingEnabled()).toBe(true); + }); +}); diff --git a/apps/hook/server/clearContextSetting.ts b/apps/hook/server/clearContextSetting.ts new file mode 100644 index 000000000..7cb1f62f2 --- /dev/null +++ b/apps/hook/server/clearContextSetting.ts @@ -0,0 +1,105 @@ +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "fs"; +import { randomBytes } from "crypto"; +import { homedir } from "os"; +import { dirname, join } from "path"; + +const SETTING_KEY = "showClearContextOnPlanAccept"; + +function consentPath(): string { + return join( + homedir(), + ".plannotator", + "consent", + "clear-context-setting.json", + ); +} + +function settingsPath(): string { + return join(homedir(), ".claude", "settings.json"); +} + +function hasConsent(): boolean { + try { + if (!existsSync(consentPath())) return false; + const data = JSON.parse(readFileSync(consentPath(), "utf8")); + return data?.consented === true; + } catch { + return false; + } +} + +function writeJsonAtomic(path: string, data: Record): void { + const tmp = join( + dirname(path), + `plannotator-settings-${randomBytes(4).toString("hex")}.json`, + ); + writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf8"); + renameSync(tmp, path); +} + +export function recordConsent(): void { + const dir = join(homedir(), ".plannotator", "consent"); + mkdirSync(dir, { recursive: true }); + writeJsonAtomic(consentPath(), { + consented: true, + recordedAt: new Date().toISOString(), + }); +} + +export function isClearContextSettingEnabled(): boolean { + try { + if (!existsSync(settingsPath())) return false; + const settings = JSON.parse(readFileSync(settingsPath(), "utf8")); + return settings?.[SETTING_KEY] === true; + } catch { + return false; + } +} + +export async function ensureClearContextSettingEnabled(): Promise { + if (!hasConsent()) { + console.error( + "[plannotator] clearContextSetting: no consent recorded; skipping settings mutation", + ); + return isClearContextSettingEnabled(); + } + + let settings: Record; + try { + settings = existsSync(settingsPath()) + ? JSON.parse(readFileSync(settingsPath(), "utf8")) + : {}; + } catch (error: any) { + console.error( + `[plannotator] clearContextSetting: malformed settings JSON; skipping mutation: ${error?.message}`, + ); + return false; + } + + if (settings[SETTING_KEY] === true) return true; + + settings[SETTING_KEY] = true; + mkdirSync(join(homedir(), ".claude"), { recursive: true }); + + try { + writeJsonAtomic(settingsPath(), settings); + } catch (error: any) { + try { + await Bun.sleep(50); + writeJsonAtomic(settingsPath(), settings); + } catch (retryError: any) { + console.error( + `[plannotator] clearContextSetting: write failed after retry; skipping: ${retryError?.message}`, + ); + return false; + } + } + + return true; +} diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index b1e687974..1bbdb6c7f 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -110,6 +110,7 @@ import { isTopLevelHelpInvocation, isVersionInvocation, } from "./cli"; +import { ensureClearContextSettingEnabled } from "./clearContextSetting"; import path from "path"; import { tmpdir } from "os"; @@ -1202,6 +1203,12 @@ if (args[0] === "sessions") { } permissionMode = event.permission_mode || "default"; + const toolName: string = + typeof event.tool_name === "string" + ? event.tool_name + : typeof event.toolName === "string" + ? event.toolName + : ""; if (!planContent) { console.error("No plan content in hook event"); @@ -1215,6 +1222,7 @@ if (args[0] === "sessions") { plan: planContent, origin: isGemini ? "gemini-cli" : detectedOrigin, permissionMode, + toolName, sharingEnabled, shareBaseUrl, pasteApiUrl, @@ -1265,6 +1273,19 @@ if (args[0] === "sessions") { } } else { // Claude Code: PermissionRequest hook decision + if ( + result.approved && + result.deferToNativeForClear && + toolName === "ExitPlanMode" + ) { + const nativeClearEnabled = await ensureClearContextSettingEnabled(); + if (nativeClearEnabled) { + process.exit(0); + } + result.clearContextNudge = true; + result.permissionMode ||= "bypassPermissions"; + } + if (result.approved) { const updatedPermissions = []; if (result.permissionMode) { diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index a0dc5215d..97d38117c 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -174,6 +174,39 @@ describe("pi review server", () => { } }); + test("plan clear-context setting endpoints are explicit unsupported fallbacks", async () => { + const homeDir = makeTempDir("plannotator-pi-home-"); + const repoDir = makeTempDir("plannotator-pi-plan-"); + process.env.HOME = homeDir; + process.chdir(repoDir); + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + const server = await startPlanReviewServer({ + plan: "# Plan\n\nShip it.", + htmlContent: "plan", + origin: "pi", + permissionMode: "acceptEdits", + }); + + try { + const statusResponse = await fetch(`${server.url}/api/settings-status`); + await expect(statusResponse.json()).resolves.toEqual({ + settingEnabled: false, + consentGiven: false, + }); + + const enableResponse = await fetch(`${server.url}/api/enable-clear-context`, { + method: "POST", + }); + await expect(enableResponse.json()).resolves.toEqual({ + ok: false, + reason: "not-supported-in-pi-extension", + }); + } finally { + server.stop(); + } + }); + test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = initRepo(); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 9df014487..5b95e74ed 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -444,6 +444,16 @@ export async function startPlanReviewServer(options: { clearContextNudge, }); json(res, { ok: true, savedPath }); + } else if ( + url.pathname === "/api/settings-status" && + req.method === "GET" + ) { + json(res, { settingEnabled: false, consentGiven: false }); + } else if ( + url.pathname === "/api/enable-clear-context" && + req.method === "POST" + ) { + json(res, { ok: false, reason: "not-supported-in-pi-extension" }); } else if (url.pathname === "/api/deny" && req.method === "POST") { if (decisionSettled) { json(res, { ok: true, duplicate: true }); diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 4f2fc2e1a..4c321364f 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -76,6 +76,7 @@ import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; +import type { ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -149,6 +150,9 @@ const App: React.FC = () => { }, []); const [isApiMode, setIsApiMode] = useState(false); const [origin, setOrigin] = useState(null); + const [pendingToolName, setPendingToolName] = useState(); + const [showClearContextBanner, setShowClearContextBanner] = useState(false); + const [pendingApprovalOverride, setPendingApprovalOverride] = useState({}); const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); const [globalAttachments, setGlobalAttachments] = useState([]); @@ -778,6 +782,9 @@ const App: React.FC = () => { if (data.versionInfo) { setVersionInfo(data.versionInfo); } + if (data.toolName) { + setPendingToolName(data.toolName); + } if (data.origin) { setOrigin(data.origin); // For Claude Code, check if user needs to configure permission mode @@ -785,7 +792,8 @@ const App: React.FC = () => { setShowPermissionModeSetup(true); } // Load saved permission mode preference - setPermissionMode(getPermissionModeSettings().mode); + const savedPermissionMode = getPermissionModeSettings().mode; + setPermissionMode(savedPermissionMode); } if (data.isWSL) { setIsWSL(true); @@ -942,7 +950,7 @@ const App: React.FC = () => { }; // API mode handlers - const handleApprove = async () => { + const handleApprove = async (override: ApprovalOverride = {}) => { setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); @@ -953,6 +961,19 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; + const shouldUseNativeClear = + origin === 'claude-code' && + pendingToolName === 'ExitPlanMode' && + (override.deferToNativeForClear || (override.permissionMode ?? permissionMode) === 'bypassPermissionsClearReminder'); + if (shouldUseNativeClear) { + try { + const response = await fetch('/api/enable-clear-context', { method: 'POST' }); + if (response.ok) setShowClearContextBanner(false); + } catch { + setShowClearContextBanner(true); + } + } + const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -960,14 +981,7 @@ const App: React.FC = () => { override, effectiveAgent, planSaveSettings, - }); - const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); - const body = buildApprovalRequestBody({ - origin, - permissionMode, - override, - effectiveAgent, - planSaveSettings, + toolName: pendingToolName, }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); @@ -1039,6 +1053,39 @@ const App: React.FC = () => { } }; + const approveWithClaudeCodeWarning = useCallback((override: ApprovalOverride = {}) => { + setPendingApprovalOverride(override); + if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { + setShowClaudeCodeWarning(true); + return; + } + handleApprove(override); + }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); + + const claudeCodeExtraEntries = useMemo(() => { + if (origin !== 'claude-code') return []; + if (pendingToolName === 'ExitPlanMode') { + return [{ + id: 'approve-bypass-native-clear', + label: 'Approve + Bypass + Clear Context (native)', + description: "Defers to Claude Code's native plan-accept dialog so it can clear context and set bypass permissions.", + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }), + }]; + } + return [{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly outside plan acceptance.', + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }), + }]; + }, [approveWithClaudeCodeWarning, origin, pendingToolName]); + // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); @@ -1635,6 +1682,7 @@ const App: React.FC = () => { canShareCurrentSession={canShareCurrentSession} agentName={agentName} availableAgents={availableAgents} + approveExtraEntries={claudeCodeExtraEntries} showAnnotationsWarning={allAnnotations.length > 0 || codeAnnotations.length > 0} callbackConfig={callbackConfig} taterMode={taterMode} @@ -1670,7 +1718,6 @@ const App: React.FC = () => { bearConfigured={getBearSettings().enabled} octarineConfigured={isOctarineConfigured()} /> - {/* Linked document error banner */} {linkedDocHook.error && (
@@ -2015,7 +2062,9 @@ const App: React.FC = () => { onClose={() => setShowClaudeCodeWarning(false)} onConfirm={() => { setShowClaudeCodeWarning(false); - handleApprove(); + const override = pendingApprovalOverride; + setPendingApprovalOverride({}); + handleApprove(override); }} title="Annotations Won't Be Sent" message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.} @@ -2133,6 +2182,66 @@ const App: React.FC = () => { {/* Update notification */} + {showClearContextBanner && ( +
+
+ Enable native clear-on-accept? +
+
+ Plannotator will write{' '} + showClearContextOnPlanAccept: true to your Claude + Code settings so Claude Code can clear planning context through + its native approval flow. +
+
+ + +
+
+ )} + {/* Image Annotator for pasted images */} { + test('maps bypass clear reminder mode to native Claude Code clear on ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'ExitPlanMode', + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + planSave: { enabled: true }, + }); + }); + + test('maps bypass clear reminder mode to reminder fallback outside ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'OtherTool', + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + test('omits agentSwitch for Claude Code approvals', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', @@ -19,6 +45,37 @@ describe('buildApprovalRequestBody', () => { }); }); + test('keeps bypass clear reminder override fallback fields for Claude Code approvals without ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + override: { + permissionMode: 'bypassPermissionsClearReminder', + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('uses native clear for bypass clear reminder override when ExitPlanMode is known', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + toolName: 'ExitPlanMode', + override: { + permissionMode: 'bypassPermissionsClearReminder', + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + planSave: { enabled: true }, + }); + }); + test('keeps agentSwitch for OpenCode approvals', () => { expect(buildApprovalRequestBody({ origin: 'opencode', @@ -30,4 +87,60 @@ describe('buildApprovalRequestBody', () => { planSave: { enabled: true }, }); }); + + test('ignores bypass clear reminder mode for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'bypassPermissionsClearReminder', + effectiveAgent: 'build', + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); + + test('forwards deferToNativeForClear for Claude Code bypass approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + override: { + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + planSave: { enabled: true }, + }); + }); + + test('does not forward deferToNativeForClear for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + override: { + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); + + test('does not forward deferToNativeForClear for Gemini origin', () => { + expect(buildApprovalRequestBody({ + origin: 'gemini-cli', + permissionMode: 'acceptEdits', + override: { + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + planSave: { enabled: true }, + }); + }); }); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index 7ebb4b834..a5e1ba7b4 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -4,6 +4,7 @@ import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; export type ApprovalOverride = { permissionMode?: PermissionMode; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }; export interface ApprovalRequestBody { @@ -15,6 +16,7 @@ export interface ApprovalRequestBody { planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; } export function buildApprovalRequestBody(options: { @@ -23,13 +25,21 @@ export function buildApprovalRequestBody(options: { override?: ApprovalOverride; effectiveAgent?: string; planSaveSettings: { enabled: boolean; customPath?: string | null }; + toolName?: string; }): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings, toolName } = options; const body: ApprovalRequestBody = {}; if (origin === 'claude-code') { - body.permissionMode = override.permissionMode ?? permissionMode; - if (override.clearContextNudge) { + const effectivePermissionMode = override.permissionMode ?? permissionMode; + const wantsClearContext = effectivePermissionMode === 'bypassPermissionsClearReminder'; + const useNativeClear = override.deferToNativeForClear || (wantsClearContext && toolName === 'ExitPlanMode'); + + body.permissionMode = wantsClearContext ? 'bypassPermissions' : effectivePermissionMode; + + if (useNativeClear) { + body.deferToNativeForClear = true; + } else if (override.clearContextNudge || wantsClearContext) { body.clearContextNudge = true; } } diff --git a/packages/server/index.ts b/packages/server/index.ts index c71677346..7a354bd0f 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -13,7 +13,10 @@ */ import type { Origin } from "@plannotator/shared/agents"; -import { resolve } from "path"; +import { randomBytes } from "crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { dirname, join, resolve } from "path"; import { isRemoteSession, getServerHostname, getServerPort } from "./remote"; import { openEditorDiff } from "./ide"; import { @@ -71,6 +74,8 @@ export interface ServerOptions { htmlContent: string; /** Current permission mode to preserve (Claude Code only) */ permissionMode?: string; + /** Tool name from the permission request, e.g. ExitPlanMode */ + toolName?: string; /** Whether URL sharing is enabled (default: true) */ sharingEnabled?: boolean; /** Custom base URL for share links (default: https://share.plannotator.ai) */ @@ -102,6 +107,7 @@ export interface ServerResult { agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -113,6 +119,64 @@ export interface ServerResult { const MAX_RETRIES = 5; const RETRY_DELAY_MS = 500; +const CLEAR_CONTEXT_SETTING_KEY = "showClearContextOnPlanAccept"; + +function clearContextConsentPath(): string { + return join(homedir(), ".plannotator", "consent", "clear-context-setting.json"); +} + +function clearContextSettingsPath(): string { + return join(homedir(), ".claude", "settings.json"); +} + +function readClearContextSettings(): Record | null { + if (!existsSync(clearContextSettingsPath())) return {}; + try { + const parsed = JSON.parse(readFileSync(clearContextSettingsPath(), "utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : {}; + } catch { + return null; + } +} + +function hasClearContextConsent(): boolean { + try { + if (!existsSync(clearContextConsentPath())) return false; + const parsed = JSON.parse(readFileSync(clearContextConsentPath(), "utf8")); + return parsed?.consented === true; + } catch { + return false; + } +} + +function writeJsonAtomic(path: string, data: Record): void { + const tmp = join( + dirname(path), + `plannotator-settings-${randomBytes(4).toString("hex")}.json`, + ); + writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf8"); + renameSync(tmp, path); +} + +function recordClearContextConsent(): void { + mkdirSync(join(homedir(), ".plannotator", "consent"), { recursive: true }); + writeJsonAtomic(clearContextConsentPath(), { + consented: true, + recordedAt: new Date().toISOString(), + }); +} + +function enableClearContextSetting(): "ok" | "malformed" { + const settings = readClearContextSettings(); + if (settings === null) return "malformed"; + settings[CLEAR_CONTEXT_SETTING_KEY] = true; + mkdirSync(join(homedir(), ".claude"), { recursive: true }); + recordClearContextConsent(); + writeJsonAtomic(clearContextSettingsPath(), settings); + return "ok"; +} /** * Start the Plannotator server @@ -126,7 +190,7 @@ const RETRY_DELAY_MS = 500; export async function startPlannotatorServer( options: ServerOptions ): Promise { - const { plan, origin, htmlContent, permissionMode, sharingEnabled = true, shareBaseUrl, pasteApiUrl, onReady, mode, customPlanPath } = options; + const { plan, origin, htmlContent, permissionMode, toolName, sharingEnabled = true, shareBaseUrl, pasteApiUrl, onReady, mode, customPlanPath } = options; const isRemote = isRemoteSession(); const configuredPort = getServerPort(); @@ -174,6 +238,7 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -182,6 +247,7 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; if (mode !== "archive") { @@ -287,7 +353,7 @@ export async function startPlannotatorServer( serverConfig: getServerConfig(gitUser), }); } - return Response.json({ plan, origin, permissionMode, sharingEnabled, shareBaseUrl, pasteApiUrl, repoInfo, previousPlan, versionInfo, projectRoot: process.cwd(), isWSL: wslFlag, serverConfig: getServerConfig(gitUser) }); + return Response.json({ plan, origin, permissionMode, toolName, sharingEnabled, shareBaseUrl, pasteApiUrl, repoInfo, previousPlan, versionInfo, projectRoot: process.cwd(), isWSL: wslFlag, serverConfig: getServerConfig(gitUser) }); } // API: Serve a linked markdown document @@ -459,6 +525,7 @@ export async function startPlannotatorServer( let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; let clearContextNudge: boolean | undefined; + let deferToNativeForClear: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -471,6 +538,7 @@ export async function startPlannotatorServer( planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -492,6 +560,9 @@ export async function startPlannotatorServer( if (body.clearContextNudge === true) { clearContextNudge = true; } + if (body.deferToNativeForClear === true) { + deferToNativeForClear = true; + } // Capture plan save settings if (body.planSave !== undefined) { @@ -538,10 +609,35 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge, deferToNativeForClear }); return Response.json({ ok: true, savedPath }); } + if (url.pathname === "/api/settings-status" && req.method === "GET") { + if (origin !== "claude-code" || toolName !== "ExitPlanMode") { + return Response.json({ error: "Unsupported clear-context flow" }, { status: 404 }); + } + const settings = readClearContextSettings(); + return Response.json({ + settingEnabled: settings?.[CLEAR_CONTEXT_SETTING_KEY] === true, + consentGiven: hasClearContextConsent(), + }); + } + + if (url.pathname === "/api/enable-clear-context" && req.method === "POST") { + if (origin !== "claude-code" || toolName !== "ExitPlanMode") { + return Response.json({ error: "Unsupported clear-context flow" }, { status: 404 }); + } + const result = enableClearContextSetting(); + if (result === "malformed") { + return Response.json( + { ok: false, error: "Malformed Claude Code settings JSON" }, + { status: 400 }, + ); + } + return Response.json({ ok: true }); + } + // API: Deny with feedback if (url.pathname === "/api/deny" && req.method === "POST") { let feedback = "Plan rejected by user"; diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index e2982315b..48a4bdb6f 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -80,6 +80,7 @@ interface SettingsProps { origin?: Origin | null; mode?: 'plan' | 'review'; onUIPreferencesChange?: (prefs: UIPreferences) => void; + onPermissionModeChange?: (mode: PermissionMode) => void; /** Externally controlled open state (for mobile menu integration) */ externalOpen?: boolean; onExternalClose?: () => void; @@ -599,7 +600,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, onPermissionModeChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -803,6 +804,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const handlePermissionModeChange = (mode: PermissionMode) => { setPermissionMode(mode); savePermissionModeSettings(mode); + onPermissionModeChange?.(mode); }; const handleDefaultNotesAppChange = (app: DefaultNotesApp) => { diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index 809995377..ed72dc4f1 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,6 +6,7 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls + * - bypassPermissionsClearReminder: Persisted UI mode that uses native clear-on-accept for Claude Code plan approvals and a /clear reminder fallback otherwise * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -15,7 +16,7 @@ import { storage } from './storage'; const STORAGE_KEY_MODE = 'plannotator-permission-mode'; const STORAGE_KEY_CONFIGURED = 'plannotator-permission-mode-configured'; -export type PermissionMode = 'bypassPermissions' | 'acceptEdits' | 'default'; +export type PermissionMode = 'bypassPermissions' | 'bypassPermissionsClearReminder' | 'acceptEdits' | 'default'; export interface PermissionModeSettings { mode: PermissionMode; @@ -33,6 +34,11 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de label: 'Bypass Permissions', description: 'Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)', }, + { + value: 'bypassPermissionsClearReminder', + label: 'Bypass + Clear Context', + description: 'For Claude Code plan approvals, defer to the native clear-context flow and bypass permissions; otherwise emit a /clear reminder.', + }, { value: 'default', label: 'Manual Approval', @@ -42,15 +48,19 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de const DEFAULT_MODE: PermissionMode = 'acceptEdits'; +function isPermissionMode(value: string | null): value is PermissionMode { + return PERMISSION_MODE_OPTIONS.some((option) => option.value === value); +} + /** * Get current permission mode settings from storage */ export function getPermissionModeSettings(): PermissionModeSettings { - const mode = storage.getItem(STORAGE_KEY_MODE) as PermissionMode | null; + const mode = storage.getItem(STORAGE_KEY_MODE); const configured = storage.getItem(STORAGE_KEY_CONFIGURED) === 'true'; return { - mode: mode || DEFAULT_MODE, + mode: isPermissionMode(mode) ? mode : DEFAULT_MODE, configured, }; } From 02117770ef5bf3e8d54931a40d34b0e88e318769 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Mon, 11 May 2026 10:49:46 +0700 Subject: [PATCH 04/39] feat(hook): auto-confirm native plan-accept dialog via keystroke injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When user clicks "Approve + Bypass + Clear Context (native)" in plannotator UI, the hook now spawns a detached background process before exiting 0 (native passthrough). The process injects "1\n" into the CC terminal after a 600ms delay, auto-selecting "Yes, clear context and bypass permissions" without a manual keypress. Detection priority: 1. tmux ($TMUX_PANE) → tmux send-keys (no accessibility permissions needed) 2. macOS → osascript iterating {warp, iTerm2, Terminal} 3. Linux/Windows without tmux → no-op (falls back to manual press) spawnKeystrokeInjector() detaches via Bun.spawn + .unref() — parent exits immediately, child fires after delay. 7 unit tests added (265/265 pass). Build clean. Smoke-tested on WarpTerminal. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/hook/server/index.ts | 498 +++++++++++++++------ apps/hook/server/keystrokeInjector.test.ts | 118 +++++ apps/hook/server/keystrokeInjector.ts | 78 ++++ 3 files changed, 568 insertions(+), 126 deletions(-) create mode 100644 apps/hook/server/keystrokeInjector.test.ts create mode 100644 apps/hook/server/keystrokeInjector.ts diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 1bbdb6c7f..90ceba02b 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -52,10 +52,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { - startPlannotatorServer, - handleServerReady, -} from "@plannotator/server"; +import { startPlannotatorServer, handleServerReady } from "@plannotator/server"; import { startReviewServer, handleReviewServerReady, @@ -69,12 +66,36 @@ import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator import { parseReviewArgs } from "@plannotator/shared/review-args"; import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-reference"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; -import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown"; -import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree"; -import { createWorktreePool, type WorktreePool } from "@plannotator/shared/worktree-pool"; -import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; +import { + urlToMarkdown, + isConvertedSource, +} from "@plannotator/shared/url-to-markdown"; +import { + fetchRef, + createWorktree, + removeWorktree, + ensureObjectAvailable, +} from "@plannotator/shared/worktree"; +import { + createWorktreePool, + type WorktreePool, +} from "@plannotator/shared/worktree-pool"; +import { + parsePRUrl, + checkPRAuth, + fetchPR, + getCliName, + getCliInstallUrl, + getMRLabel, + getMRNumberLabel, + getDisplayRepo, +} from "@plannotator/server/pr"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; -import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; +import { + resolveMarkdownFile, + resolveUserPath, + hasMarkdownFiles, +} from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; @@ -85,7 +106,11 @@ import { getPlanToolName, buildPlanFileRule, } from "@plannotator/shared/prompts"; -import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions"; +import { + registerSession, + unregisterSession, + listSessions, +} from "@plannotator/server/sessions"; import { openBrowser } from "@plannotator/server/browser"; import { detectProjectName } from "@plannotator/server/project"; import { hostnameOrFallback } from "@plannotator/shared/project"; @@ -100,8 +125,16 @@ import { resolveSessionLogByCwdScan, type RenderedMessage, } from "./session-log"; -import { findCodexRolloutByThreadId, getLastCodexMessage, getLatestCodexPlan } from "./codex-session"; -import { findCopilotPlanContent, findCopilotSessionForCwd, getLastCopilotMessage } from "./copilot-session"; +import { + findCodexRolloutByThreadId, + getLastCodexMessage, + getLatestCodexPlan, +} from "./codex-session"; +import { + findCopilotPlanContent, + findCopilotSessionForCwd, + getLastCopilotMessage, +} from "./copilot-session"; import { formatInteractiveNoArgClarification, formatTopLevelHelp, @@ -111,6 +144,7 @@ import { isVersionInvocation, } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; +import { spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; import { tmpdir } from "os"; @@ -184,7 +218,9 @@ function emitAnnotateOutcome(result: { if (hookFlag) { if (result.approved || result.exit) return; if (result.feedback) { - console.log(JSON.stringify({ decision: "block", reason: result.feedback })); + console.log( + JSON.stringify({ decision: "block", reason: result.feedback }), + ); } return; } @@ -194,7 +230,12 @@ function emitAnnotateOutcome(result: { } else if (result.exit) { console.log(JSON.stringify({ decision: "dismissed" })); } else { - console.log(JSON.stringify({ decision: "annotated", feedback: result.feedback || "" })); + console.log( + JSON.stringify({ + decision: "annotated", + feedback: result.feedback || "", + }), + ); } return; } @@ -246,12 +287,17 @@ const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined; // packages/shared/agents.ts (see header comment there). const originOverride = process.env.PLANNOTATOR_ORIGIN as Origin | undefined; const detectedOrigin: Origin = - (originOverride && originOverride in AGENT_CONFIG) ? originOverride : - process.env.CODEX_THREAD_ID ? "codex" : - process.env.COPILOT_CLI ? "copilot-cli" : - process.env.OPENCODE ? "opencode" : - process.env.GEMINI_CLI ? "gemini-cli" : - "claude-code"; + originOverride && originOverride in AGENT_CONFIG + ? originOverride + : process.env.CODEX_THREAD_ID + ? "codex" + : process.env.COPILOT_CLI + ? "copilot-cli" + : process.env.OPENCODE + ? "opencode" + : process.env.GEMINI_CLI + ? "gemini-cli" + : "claude-code"; if (args[0] === "sessions") { // ============================================ @@ -261,7 +307,9 @@ if (args[0] === "sessions") { if (args.includes("--clean")) { // Force cleanup: list sessions (which auto-removes stale entries) const sessions = listSessions(); - console.error(`Cleaned up stale sessions. ${sessions.length} active session(s) remain.`); + console.error( + `Cleaned up stale sessions. ${sessions.length} active session(s) remain.`, + ); process.exit(0); } @@ -279,7 +327,9 @@ if (args[0] === "sessions") { const n = nArg ? parseInt(nArg, 10) : 1; const session = sessions[n - 1]; if (!session) { - console.error(`Session #${n} not found. ${sessions.length} active session(s).`); + console.error( + `Session #${n} not found. ${sessions.length} active session(s).`, + ); process.exit(1); } await openBrowser(session.url); @@ -291,13 +341,17 @@ if (args[0] === "sessions") { console.error("Active Plannotator sessions:\n"); for (let i = 0; i < sessions.length; i++) { const s = sessions[i]; - const age = Math.round((Date.now() - new Date(s.startedAt).getTime()) / 60000); - const ageStr = age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`; - console.error(` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`); + const age = Math.round( + (Date.now() - new Date(s.startedAt).getTime()) / 60000, + ); + const ageStr = + age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`; + console.error( + ` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`, + ); } console.error(`\nReopen with: plannotator sessions --open [N]`); process.exit(0); - } else if (args[0] === "review") { // ============================================ // CODE REVIEW MODE @@ -325,7 +379,9 @@ if (args[0] === "sessions") { console.error(`Invalid PR/MR URL: ${urlArg}`); console.error("Supported formats:"); console.error(" GitHub: https://github.com/owner/repo/pull/123"); - console.error(" GitLab: https://gitlab.com/group/project/-/merge_requests/42"); + console.error( + " GitLab: https://gitlab.com/group/project/-/merge_requests/42", + ); process.exit(1); } @@ -337,7 +393,9 @@ if (args[0] === "sessions") { } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("not found") || msg.includes("ENOENT")) { - console.error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`); + console.error( + `${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`, + ); console.error(`Install it from ${cliUrl}`); } else { console.error(msg); @@ -345,7 +403,9 @@ if (args[0] === "sessions") { process.exit(1); } - console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`); + console.error( + `Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`, + ); try { const pr = await fetchPR(prRef); rawPatch = pr.rawPatch; @@ -363,42 +423,68 @@ if (args[0] === "sessions") { let sessionDir: string | undefined; try { const repoDir = process.cwd(); - const identifier = prMetadata.platform === "github" - ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` - : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; + const identifier = + prMetadata.platform === "github" + ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` + : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; const suffix = Math.random().toString(36).slice(2, 8); // Resolve tmpdir to its real path — on macOS, tmpdir() returns /var/folders/... // but processes report /private/var/folders/... which breaks path stripping. - sessionDir = path.join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`); - const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; + sessionDir = path.join( + realpathSync(tmpdir()), + `plannotator-pr-${identifier}-${suffix}`, + ); + const prNumber = + prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; localPath = path.join(sessionDir, "pool", `pr-${prNumber}`); - const fetchRefStr = prMetadata.platform === "github" - ? `refs/pull/${prMetadata.number}/head` - : `refs/merge-requests/${prMetadata.iid}/head`; + const fetchRefStr = + prMetadata.platform === "github" + ? `refs/pull/${prMetadata.number}/head` + : `refs/merge-requests/${prMetadata.iid}/head`; // Validate inputs from platform API to prevent git flag/path injection - if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); - if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); + if ( + prMetadata.baseBranch.includes("..") || + prMetadata.baseBranch.startsWith("-") + ) + throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); + if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) + throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); // Detect same-repo vs cross-repo (must match both owner/repo AND host) let isSameRepo = false; try { - const remoteResult = await gitRuntime.runGit(["remote", "get-url", "origin"]); + const remoteResult = await gitRuntime.runGit([ + "remote", + "get-url", + "origin", + ]); if (remoteResult.exitCode === 0) { const remoteUrl = remoteResult.stdout.trim(); const currentRepo = parseRemoteUrl(remoteUrl); - const prRepo = prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; - const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase(); + const prRepo = + prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; + const repoMatches = + !!currentRepo && + currentRepo.toLowerCase() === prRepo.toLowerCase(); // Extract host from remote URL to avoid cross-instance false positives (GHE) const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1]; - const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })(); + const httpsHost = (() => { + try { + return new URL(remoteUrl).hostname; + } catch { + return null; + } + })(); const remoteHost = (sshHost || httpsHost || "").toLowerCase(); const prHost = prMetadata.host.toLowerCase(); isSameRepo = repoMatches && remoteHost === prHost; } - } catch { /* not in a git repo — cross-repo path */ } + } catch { + /* not in a git repo — cross-repo path */ + } if (isSameRepo) { // ── Same-repo: fast worktree path ── @@ -408,7 +494,9 @@ if (args[0] === "sessions") { // Both MUST happen before the PR head fetch since FETCH_HEAD is what // createWorktree uses — the PR head fetch must be last. await fetchRef(gitRuntime, prMetadata.baseBranch, { cwd: repoDir }); - await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { cwd: repoDir }); + await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { + cwd: repoDir, + }); // Fetch PR head LAST — sets FETCH_HEAD to the PR tip for createWorktree. await fetchRef(gitRuntime, fetchRefStr, { cwd: repoDir }); @@ -421,41 +509,65 @@ if (args[0] === "sessions") { worktreeCleanup = async () => { if (worktreePool) await worktreePool.cleanup(gitRuntime); - try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + try { + rmSync(sessionDir, { recursive: true, force: true }); + } catch {} }; process.once("exit", () => { // Best-effort sync cleanup: remove each pool worktree from git, then rm session dir try { for (const entry of worktreePool?.entries() ?? []) { - Bun.spawnSync(["git", "worktree", "remove", "--force", entry.path], { cwd: repoDir }); + Bun.spawnSync( + ["git", "worktree", "remove", "--force", entry.path], + { cwd: repoDir }, + ); } } catch {} - try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} + try { + Bun.spawnSync(["rm", "-rf", sessionDir]); + } catch {} }); } else { // ── Cross-repo: shallow clone + fetch PR head ── - const prRepo = prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; + const prRepo = + prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; // Validate repo identifier to prevent flag injection via crafted URLs - if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`); + if (/^-/.test(prRepo)) + throw new Error(`Invalid repository identifier: ${prRepo}`); const cli = prMetadata.platform === "github" ? "gh" : "glab"; const host = prMetadata.host; // gh/glab repo clone doesn't accept --hostname; set GH_HOST/GITLAB_HOST env instead const isDefaultHost = host === "github.com" || host === "gitlab.com"; - const cloneEnv = isDefaultHost ? undefined : { - ...process.env, - ...(prMetadata.platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }), - }; + const cloneEnv = isDefaultHost + ? undefined + : { + ...process.env, + ...(prMetadata.platform === "github" + ? { GH_HOST: host } + : { GITLAB_HOST: host }), + }; // Step 1: Fast skeleton clone (no checkout, depth 1 — minimal data transfer) console.error(`Cloning ${prRepo} (shallow)...`); const cloneResult = Bun.spawnSync( - [cli, "repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], + [ + cli, + "repo", + "clone", + prRepo, + localPath, + "--", + "--depth=1", + "--no-checkout", + ], { stderr: "pipe", env: cloneEnv }, ); if (cloneResult.exitCode !== 0) { - throw new Error(`${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`); + throw new Error( + `${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`, + ); } // Step 2: Fetch only the PR head ref (targeted, much faster than full fetch) @@ -464,23 +576,54 @@ if (args[0] === "sessions") { ["git", "fetch", "--depth=200", "origin", fetchRefStr], { cwd: localPath, stderr: "pipe" }, ); - if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`); + if (fetchResult.exitCode !== 0) + throw new Error( + `Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`, + ); // Step 3: Checkout PR head (critical — if this fails, worktree is empty) - const checkoutResult = Bun.spawnSync(["git", "checkout", "FETCH_HEAD"], { cwd: localPath, stderr: "pipe" }); + const checkoutResult = Bun.spawnSync( + ["git", "checkout", "FETCH_HEAD"], + { cwd: localPath, stderr: "pipe" }, + ); if (checkoutResult.exitCode !== 0) { - throw new Error(`git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`); + throw new Error( + `git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`, + ); } // Best-effort: create base refs so `git diff main...HEAD` and `git diff origin/main...HEAD` work - const baseFetch = Bun.spawnSync(["git", "fetch", "--depth=200", "origin", prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); - if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate"); - Bun.spawnSync(["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); - Bun.spawnSync(["git", "update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); + const baseFetch = Bun.spawnSync( + ["git", "fetch", "--depth=200", "origin", prMetadata.baseSha], + { cwd: localPath, stderr: "pipe" }, + ); + if (baseFetch.exitCode !== 0) + console.error( + "Warning: failed to fetch baseSha, agent diffs may be inaccurate", + ); + Bun.spawnSync( + ["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], + { cwd: localPath, stderr: "pipe" }, + ); + Bun.spawnSync( + [ + "git", + "update-ref", + `refs/remotes/origin/${prMetadata.baseBranch}`, + prMetadata.baseSha, + ], + { cwd: localPath, stderr: "pipe" }, + ); - worktreeCleanup = () => { try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} }; + worktreeCleanup = () => { + try { + rmSync(sessionDir, { recursive: true, force: true }); + } catch {} + }; process.once("exit", () => { - try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} + try { + Bun.spawnSync(["rm", "-rf", sessionDir]); + } catch {} }); } @@ -491,14 +634,22 @@ if (args[0] === "sessions") { // Create worktree pool with the initial PR as the first entry worktreePool = createWorktreePool( { sessionDir, repoDir, isSameRepo }, - { path: localPath, prUrl: prMetadata.url, number: prNumber, ready: true }, + { + path: localPath, + prUrl: prMetadata.url, + number: prNumber, + ready: true, + }, ); console.error(`Local checkout ready at ${localPath}`); } catch (err) { console.error(`Warning: --local failed, falling back to remote diff`); console.error(err instanceof Error ? err.message : String(err)); - if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + if (sessionDir) + try { + rmSync(sessionDir, { recursive: true, force: true }); + } catch {} agentCwd = undefined; worktreePool = undefined; worktreeCleanup = undefined; @@ -540,7 +691,12 @@ if (args[0] === "sessions") { handleReviewServerReady(url, isRemote, port); if (isRemote && sharingEnabled && rawPatch) { - await writeRemoteShareLink(rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => {}); + await writeRemoteShareLink( + rawPatch, + shareBaseUrl, + "review changes", + "diff only", + ).catch(() => {}); } }, }); @@ -552,7 +708,9 @@ if (args[0] === "sessions") { mode: "review", project: reviewProject, startedAt: new Date().toISOString(), - label: isPRMode ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` : `review-${reviewProject}`, + label: isPRMode + ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` + : `review-${reviewProject}`, }); // Wait for user feedback @@ -576,7 +734,6 @@ if (args[0] === "sessions") { } } process.exit(0); - } else if (args[0] === "annotate") { // ============================================ // ANNOTATE MODE @@ -584,7 +741,9 @@ if (args[0] === "sessions") { const rawFilePath = args[1]; if (!rawFilePath) { - console.error("Usage: plannotator annotate [--no-jina] [--gate] [--json] [--hook]"); + console.error( + "Usage: plannotator annotate [--no-jina] [--gate] [--json] [--hook]", + ); process.exit(1); } @@ -614,31 +773,46 @@ if (args[0] === "sessions") { if (isUrl) { const useJina = resolveUseJina(cliNoJina, loadConfig()); - console.error(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`); + console.error( + `Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`, + ); try { const result = await urlToMarkdown(filePath, { useJina }); markdown = result.markdown; sourceConverted = isConvertedSource(result.source); if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`); + console.error( + `[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`, + ); } } catch (err) { - console.error(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`); + console.error( + `Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`, + ); process.exit(1); } absolutePath = filePath; // Use URL as the "path" for display - sourceInfo = filePath; // Full URL for source attribution + sourceInfo = filePath; // Full URL for source attribution } else { // Folder check with literal-@ fallback for scoped-package-style names. const folderCandidate = resolveAtReference(rawFilePath, (c) => { - try { return statSync(resolveUserPath(c, projectRoot)).isDirectory(); } - catch { return false; } + try { + return statSync(resolveUserPath(c, projectRoot)).isDirectory(); + } catch { + return false; + } }); if (folderCandidate !== null) { const resolvedArg = resolveUserPath(folderCandidate, projectRoot); // Folder annotation mode (markdown + HTML files) - if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) { + if ( + !hasMarkdownFiles( + resolvedArg, + FILE_BROWSER_EXCLUDED, + /\.(mdx?|html?)$/i, + ) + ) { console.error(`No markdown or HTML files found in ${resolvedArg}`); process.exit(1); } @@ -658,7 +832,9 @@ if (args[0] === "sessions") { const resolvedArg = resolveUserPath(htmlCandidate, projectRoot); const htmlFile = Bun.file(resolvedArg); if (htmlFile.size > 10 * 1024 * 1024) { - console.error(`File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`); + console.error( + `File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`, + ); process.exit(1); } const html = await htmlFile.text(); @@ -681,7 +857,9 @@ if (args[0] === "sessions") { } if (resolved.kind === "ambiguous") { - console.error(`Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`); + console.error( + `Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`, + ); for (const match of resolved.matches) { console.error(` ${match}`); } @@ -721,7 +899,12 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled && markdown) { - await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {}); + await writeRemoteShareLink( + markdown, + shareBaseUrl, + "annotate", + "document only", + ).catch(() => {}); } }, }); @@ -750,7 +933,6 @@ if (args[0] === "sessions") { // Output feedback (captured by slash command) emitAnnotateOutcome(result); process.exit(0); - } else if (args[0] === "annotate-last" || args[0] === "last") { // ============================================ // ANNOTATE LAST MESSAGE MODE @@ -774,7 +956,11 @@ if (args[0] === "sessions") { } const msg = getLastCodexMessage(rolloutPath); if (msg) { - lastMessage = { messageId: codexThreadId, text: msg.text, lineNumbers: [] }; + lastMessage = { + messageId: codexThreadId, + text: msg.text, + lineNumbers: [], + }; } } } else { @@ -804,7 +990,9 @@ if (args[0] === "sessions") { if (lastMessage) return; const paths = getPaths(); if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`); + console.error( + `[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`, + ); } for (const logPath of paths) { lastMessage = getLastRenderedMessage(logPath); @@ -814,17 +1002,25 @@ if (args[0] === "sessions") { // 1. Walk ancestor PIDs for a matching session metadata file const ancestorLog = resolveSessionLogByAncestorPids(); - tryLogCandidates("Ancestor PID session metadata", () => ancestorLog ? [ancestorLog] : []); + tryLogCandidates("Ancestor PID session metadata", () => + ancestorLog ? [ancestorLog] : [], + ); // 2. Scan all session metadata files for one whose cwd matches const cwdScanLog = resolveSessionLogByCwdScan({ cwd: projectRoot }); - tryLogCandidates("Cwd-scan session metadata", () => cwdScanLog ? [cwdScanLog] : []); + tryLogCandidates("Cwd-scan session metadata", () => + cwdScanLog ? [cwdScanLog] : [], + ); // 3. Fall back to CWD slug match (mtime-based) - tryLogCandidates("CWD slug match (mtime)", () => findSessionLogsForCwd(projectRoot)); + tryLogCandidates("CWD slug match (mtime)", () => + findSessionLogsForCwd(projectRoot), + ); // 4. Fall back to ancestor directory walk - tryLogCandidates("Directory ancestor walk", () => findSessionLogsByAncestorWalk(projectRoot)); + tryLogCandidates("Directory ancestor walk", () => + findSessionLogsByAncestorWalk(projectRoot), + ); } if (!lastMessage) { @@ -833,7 +1029,9 @@ if (args[0] === "sessions") { } if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`); + console.error( + `[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`, + ); } const annotateProject = (await detectProjectName()) ?? "_unknown"; @@ -852,7 +1050,12 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(lastMessage.text, shareBaseUrl, "annotate", "message only").catch(() => {}); + await writeRemoteShareLink( + lastMessage.text, + shareBaseUrl, + "annotate", + "message only", + ).catch(() => {}); } }, }); @@ -875,7 +1078,6 @@ if (args[0] === "sessions") { emitAnnotateOutcome(result); process.exit(0); - } else if (args[0] === "archive") { // ============================================ // ARCHIVE BROWSER MODE @@ -910,7 +1112,6 @@ if (args[0] === "sessions") { await Bun.sleep(500); server.stop(); process.exit(0); - } else if (args[0] === "copilot-plan") { // ============================================ // COPILOT CLI PLAN INTERCEPTION MODE @@ -921,7 +1122,13 @@ if (args[0] === "sessions") { // No output = allow the tool call to proceed. const eventJson = await Bun.stdin.text(); - let event: { toolName: string; toolArgs: string; cwd: string; timestamp: number; sessionId?: string }; + let event: { + toolName: string; + toolArgs: string; + cwd: string; + timestamp: number; + sessionId?: string; + }; try { event = JSON.parse(eventJson); @@ -956,7 +1163,12 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink( + planContent, + shareBaseUrl, + "review the plan", + "plan only", + ).catch(() => {}); } }, }); @@ -977,23 +1189,26 @@ if (args[0] === "sessions") { // Output Copilot CLI permission decision format if (result.approved) { - console.log(JSON.stringify({ - permissionDecision: "allow", - })); + console.log( + JSON.stringify({ + permissionDecision: "allow", + }), + ); } else { const feedback = getPlanDeniedPrompt("copilot-cli", undefined, { toolName: getPlanToolName("copilot-cli"), planFileRule: "", feedback: result.feedback || "Plan changes requested", }); - console.log(JSON.stringify({ - permissionDecision: "deny", - permissionDecisionReason: feedback, - })); + console.log( + JSON.stringify({ + permissionDecision: "deny", + permissionDecisionReason: feedback, + }), + ); } process.exit(0); - } else if (args[0] === "copilot-last") { // ============================================ // COPILOT CLI ANNOTATE LAST MESSAGE MODE @@ -1002,7 +1217,9 @@ if (args[0] === "sessions") { const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`); + console.error( + `[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`, + ); } const sessionDir = findCopilotSessionForCwd(projectRoot); @@ -1041,7 +1258,12 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(msg.text, shareBaseUrl, "annotate", "message only").catch(() => {}); + await writeRemoteShareLink( + msg.text, + shareBaseUrl, + "annotate", + "message only", + ).catch(() => {}); } }, }); @@ -1062,7 +1284,6 @@ if (args[0] === "sessions") { emitAnnotateOutcome(result); process.exit(0); - } else if (args[0] === "improve-context") { // ============================================ // IMPROVEMENT HOOK CONTEXT INJECTION MODE @@ -1085,15 +1306,16 @@ if (args[0] === "sessions") { if (context === null) process.exit(0); - console.log(JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PreToolUse", - additionalContext: context, - }, - })); + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: context, + }, + }), + ); process.exit(0); - } else { // ============================================ // PLAN REVIEW MODE (default) @@ -1145,7 +1367,12 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(latestPlan.text, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink( + latestPlan.text, + shareBaseUrl, + "review the plan", + "plan only", + ).catch(() => {}); } }, }); @@ -1175,7 +1402,7 @@ if (args[0] === "sessions") { planFileRule: "", feedback: result.feedback || "Plan changes requested", }), - }) + }), ); } @@ -1188,7 +1415,8 @@ if (args[0] === "sessions") { let planFilename = ""; // Detect harness: Gemini sends plan_filename (file on disk), Claude Code sends plan (inline) - planFilename = event.tool_input?.plan_filename || event.tool_input?.plan_path || ""; + planFilename = + event.tool_input?.plan_filename || event.tool_input?.plan_path || ""; isGemini = !!planFilename; if (isGemini) { @@ -1196,7 +1424,12 @@ if (args[0] === "sessions") { // transcript_path = /chats/session-...json // plan lives at = //plans/ const projectTempDir = path.dirname(path.dirname(event.transcript_path)); - const planFilePath = path.join(projectTempDir, event.session_id, "plans", planFilename); + const planFilePath = path.join( + projectTempDir, + event.session_id, + "plans", + planFilename, + ); planContent = await Bun.file(planFilePath).text(); } else { planContent = event.tool_input?.plan || ""; @@ -1231,7 +1464,12 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink( + planContent, + shareBaseUrl, + "review the plan", + "plan only", + ).catch(() => {}); } }, }); @@ -1258,17 +1496,24 @@ if (args[0] === "sessions") { // Output decision in the appropriate format for the harness if (isGemini) { if (result.approved) { - console.log(result.feedback ? JSON.stringify({ systemMessage: result.feedback }) : "{}"); + console.log( + result.feedback + ? JSON.stringify({ systemMessage: result.feedback }) + : "{}", + ); } else { console.log( JSON.stringify({ decision: "deny", reason: getPlanDeniedPrompt("gemini-cli", undefined, { toolName: getPlanToolName("gemini-cli"), - planFileRule: buildPlanFileRule(getPlanToolName("gemini-cli"), planFilename), + planFileRule: buildPlanFileRule( + getPlanToolName("gemini-cli"), + planFilename, + ), feedback: result.feedback || "Plan changes requested", }), - }) + }), ); } } else { @@ -1280,6 +1525,7 @@ if (args[0] === "sessions") { ) { const nativeClearEnabled = await ensureClearContextSettingEnabled(); if (nativeClearEnabled) { + spawnKeystrokeInjector(); process.exit(0); } result.clearContextNudge = true; @@ -1309,7 +1555,7 @@ if (args[0] === "sessions") { ...(updatedPermissions.length > 0 && { updatedPermissions }), }, }, - }) + }), ); } else { console.log( @@ -1325,7 +1571,7 @@ if (args[0] === "sessions") { }), }, }, - }) + }), ); } } diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts new file mode 100644 index 000000000..8d6063c45 --- /dev/null +++ b/apps/hook/server/keystrokeInjector.test.ts @@ -0,0 +1,118 @@ +import { + describe, + it, + expect, + spyOn, + mock, + beforeEach, + afterEach, +} from "bun:test"; +import { spawnKeystrokeInjector } from "./keystrokeInjector"; + +describe("spawnKeystrokeInjector", () => { + let spawnCalls: { cmd: string[]; opts: Record }[] = []; + let originalEnv: NodeJS.ProcessEnv; + let originalPlatform: string; + + beforeEach(() => { + spawnCalls = []; + originalEnv = { ...process.env }; + originalPlatform = process.platform; + delete process.env["TMUX_PANE"]; + + const mockChild = { unref: mock(() => {}) }; + spyOn(Bun, "spawn").mockImplementation( + (cmd: string[], opts: Record) => { + spawnCalls.push({ cmd, opts }); + return mockChild as ReturnType; + }, + ); + }); + + afterEach(() => { + process.env = originalEnv; + Object.defineProperty(process, "platform", { + value: originalPlatform, + writable: true, + }); + mock.restore(); + }); + + it("uses tmux send-keys when TMUX_PANE is set", () => { + process.env["TMUX_PANE"] = "%3"; + spawnKeystrokeInjector(100); + + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0].cmd[0]).toBe("bash"); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("tmux send-keys"); + expect(script).toContain("%3"); + expect(script).toContain("1 Enter"); + expect(script).not.toContain("osascript"); + }); + + it("uses osascript on macOS when no tmux pane", () => { + Object.defineProperty(process, "platform", { + value: "darwin", + writable: true, + }); + spawnKeystrokeInjector(100); + + expect(spawnCalls).toHaveLength(1); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("osascript"); + expect(script).toContain("warp"); + expect(script).toContain("iTerm2"); + expect(script).toContain("Terminal"); + expect(script).toContain('keystroke "1"'); + expect(script).not.toContain("tmux send-keys"); + }); + + it("no-op on linux without tmux", () => { + Object.defineProperty(process, "platform", { + value: "linux", + writable: true, + }); + spawnKeystrokeInjector(); + expect(spawnCalls).toHaveLength(0); + }); + + it("no-op on windows without tmux", () => { + Object.defineProperty(process, "platform", { + value: "win32", + writable: true, + }); + spawnKeystrokeInjector(); + expect(spawnCalls).toHaveLength(0); + }); + + it("spawn is detached and unreffed (does not block caller)", () => { + Object.defineProperty(process, "platform", { + value: "darwin", + writable: true, + }); + spawnKeystrokeInjector(); + + expect(spawnCalls).toHaveLength(1); + const opts = spawnCalls[0].opts as { detached: boolean; stdio: string[] }; + expect(opts.detached).toBe(true); + expect(opts.stdio).toEqual(["ignore", "ignore", "ignore"]); + }); + + it("embeds delay in tmux script via sleep", () => { + process.env["TMUX_PANE"] = "%0"; + spawnKeystrokeInjector(1200); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("sleep 1.20"); + }); + + it("embeds delay in osascript via 'delay' statement", () => { + Object.defineProperty(process, "platform", { + value: "darwin", + writable: true, + }); + spawnKeystrokeInjector(800); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("delay 0.80"); + }); +}); diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts new file mode 100644 index 000000000..7ecfa31aa --- /dev/null +++ b/apps/hook/server/keystrokeInjector.ts @@ -0,0 +1,78 @@ +/** + * Detached keystroke injector for auto-confirming CC's native plan-accept dialog. + * + * CC renders the plan-accept dialog in the terminal ~200–500ms after the hook + * process exits. This module spawns a background process that fires "1\n" into + * the active CC terminal window after a configurable delay, selecting + * "Yes, clear context and bypass permissions" without user interaction. + * + * Platform strategy: + * 1. tmux ($TMUX_PANE) → tmux send-keys (no accessibility permissions needed) + * 2. macOS → osascript targeting WarpTerminal, iTerm2, or Terminal + * 3. everything else → no-op (user must press 1 manually) + * + * Silent-fail contract: any error (accessibility denied, no terminal found, etc.) + * exits the child process non-zero without affecting the hook's exit or logging noise. + */ + +const KNOWN_MACOS_TERMINALS = ["warp", "iTerm2", "Terminal"] as const; + +function buildTmuxScript(pane: string, delayMs: number): string { + const delaySec = (delayMs / 1000).toFixed(2); + return `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(pane)} 1 Enter`; +} + +function buildOsascriptScript(delayMs: number): string { + const delaySec = (delayMs / 1000).toFixed(2); + const appList = KNOWN_MACOS_TERMINALS.map((a) => `"${a}"`).join(", "); + // prettier-ignore + return [ + `osascript <<'APPLESCRIPT'`, + `delay ${delaySec}`, + `tell application "System Events"`, + ` repeat with appName in {${appList}}`, + ` if exists (application process (appName as string)) then`, + ` set frontmost of application process (appName as string) to true`, + ` delay 0.05`, + ` keystroke "1"`, + ` key code 36`, + ` exit repeat`, + ` end if`, + ` end repeat`, + `end tell`, + `APPLESCRIPT`, + ].join("\n"); +} + +/** + * Spawn a detached process that injects "1\n" into the CC terminal window. + * Returns immediately; the child continues running after the parent exits. + * + * @param delayMs Milliseconds to wait before sending the keystroke (default: 600). + * Should be longer than CC's dialog render time (~200–500ms). + */ +export function spawnKeystrokeInjector(delayMs = 600): void { + const tmuxPane = process.env["TMUX_PANE"]; + + if (tmuxPane) { + const script = buildTmuxScript(tmuxPane, delayMs); + const child = Bun.spawn(["bash", "-c", script], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }); + child.unref(); + return; + } + + if (process.platform === "darwin") { + const script = buildOsascriptScript(delayMs); + const child = Bun.spawn(["bash", "-c", script], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }); + child.unref(); + return; + } + + // Linux/Windows without tmux: no-op; user must press 1 manually. +} From 2f1d5a96b1a28a7b4866608c03d645c7af88ea25 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Mon, 11 May 2026 10:58:24 +0700 Subject: [PATCH 05/39] =?UTF-8?q?refactor(hook):=20deslop=20keystrokeInjec?= =?UTF-8?q?tor=20=E2=80=94=20collapse=20builders,=20unify=20spawn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural cleanup of the keystroke injector without behavior changes: - Delete 16-line module docblock + 5-line JSDoc (over-commented for 78 lines) - Inline buildTmuxScript() and buildOsascriptScript() — single-use helpers - Unify the duplicated Bun.spawn+.unref() into one call at the end: both branches now set `script: string | null`, then a single spawn runs if set - `KNOWN_MACOS_TERMINALS as const` → plain string[] (not a readonly tuple) Test cleanup: - Extract setPlatform(v) helper — replaces 5× Object.defineProperty blocks 78→39 lines (implementation), 118→107 lines (tests). 7/7 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/hook/server/keystrokeInjector.test.ts | 29 ++----- apps/hook/server/keystrokeInjector.ts | 99 +++++++--------------- 2 files changed, 39 insertions(+), 89 deletions(-) diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts index 8d6063c45..d3cc9b94d 100644 --- a/apps/hook/server/keystrokeInjector.test.ts +++ b/apps/hook/server/keystrokeInjector.test.ts @@ -14,6 +14,10 @@ describe("spawnKeystrokeInjector", () => { let originalEnv: NodeJS.ProcessEnv; let originalPlatform: string; + function setPlatform(v: string) { + Object.defineProperty(process, "platform", { value: v, writable: true }); + } + beforeEach(() => { spawnCalls = []; originalEnv = { ...process.env }; @@ -52,10 +56,7 @@ describe("spawnKeystrokeInjector", () => { }); it("uses osascript on macOS when no tmux pane", () => { - Object.defineProperty(process, "platform", { - value: "darwin", - writable: true, - }); + setPlatform("darwin"); spawnKeystrokeInjector(100); expect(spawnCalls).toHaveLength(1); @@ -69,28 +70,19 @@ describe("spawnKeystrokeInjector", () => { }); it("no-op on linux without tmux", () => { - Object.defineProperty(process, "platform", { - value: "linux", - writable: true, - }); + setPlatform("linux"); spawnKeystrokeInjector(); expect(spawnCalls).toHaveLength(0); }); it("no-op on windows without tmux", () => { - Object.defineProperty(process, "platform", { - value: "win32", - writable: true, - }); + setPlatform("win32"); spawnKeystrokeInjector(); expect(spawnCalls).toHaveLength(0); }); it("spawn is detached and unreffed (does not block caller)", () => { - Object.defineProperty(process, "platform", { - value: "darwin", - writable: true, - }); + setPlatform("darwin"); spawnKeystrokeInjector(); expect(spawnCalls).toHaveLength(1); @@ -107,10 +99,7 @@ describe("spawnKeystrokeInjector", () => { }); it("embeds delay in osascript via 'delay' statement", () => { - Object.defineProperty(process, "platform", { - value: "darwin", - writable: true, - }); + setPlatform("darwin"); spawnKeystrokeInjector(800); const script = spawnCalls[0].cmd[2] as string; expect(script).toContain("delay 0.80"); diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts index 7ecfa31aa..907ffebd7 100644 --- a/apps/hook/server/keystrokeInjector.ts +++ b/apps/hook/server/keystrokeInjector.ts @@ -1,78 +1,39 @@ -/** - * Detached keystroke injector for auto-confirming CC's native plan-accept dialog. - * - * CC renders the plan-accept dialog in the terminal ~200–500ms after the hook - * process exits. This module spawns a background process that fires "1\n" into - * the active CC terminal window after a configurable delay, selecting - * "Yes, clear context and bypass permissions" without user interaction. - * - * Platform strategy: - * 1. tmux ($TMUX_PANE) → tmux send-keys (no accessibility permissions needed) - * 2. macOS → osascript targeting WarpTerminal, iTerm2, or Terminal - * 3. everything else → no-op (user must press 1 manually) - * - * Silent-fail contract: any error (accessibility denied, no terminal found, etc.) - * exits the child process non-zero without affecting the hook's exit or logging noise. - */ +// Injects "1\n" into CC terminal to auto-select the plan-accept "clear + bypass" option. +const MACOS_TERMINALS = ["warp", "iTerm2", "Terminal"]; -const KNOWN_MACOS_TERMINALS = ["warp", "iTerm2", "Terminal"] as const; - -function buildTmuxScript(pane: string, delayMs: number): string { - const delaySec = (delayMs / 1000).toFixed(2); - return `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(pane)} 1 Enter`; -} - -function buildOsascriptScript(delayMs: number): string { - const delaySec = (delayMs / 1000).toFixed(2); - const appList = KNOWN_MACOS_TERMINALS.map((a) => `"${a}"`).join(", "); - // prettier-ignore - return [ - `osascript <<'APPLESCRIPT'`, - `delay ${delaySec}`, - `tell application "System Events"`, - ` repeat with appName in {${appList}}`, - ` if exists (application process (appName as string)) then`, - ` set frontmost of application process (appName as string) to true`, - ` delay 0.05`, - ` keystroke "1"`, - ` key code 36`, - ` exit repeat`, - ` end if`, - ` end repeat`, - `end tell`, - `APPLESCRIPT`, - ].join("\n"); -} - -/** - * Spawn a detached process that injects "1\n" into the CC terminal window. - * Returns immediately; the child continues running after the parent exits. - * - * @param delayMs Milliseconds to wait before sending the keystroke (default: 600). - * Should be longer than CC's dialog render time (~200–500ms). - */ export function spawnKeystrokeInjector(delayMs = 600): void { + const delaySec = (delayMs / 1000).toFixed(2); const tmuxPane = process.env["TMUX_PANE"]; + let script: string | null = null; + if (tmuxPane) { - const script = buildTmuxScript(tmuxPane, delayMs); - const child = Bun.spawn(["bash", "-c", script], { - detached: true, - stdio: ["ignore", "ignore", "ignore"], - }); - child.unref(); - return; + script = `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(tmuxPane)} 1 Enter`; + } else if (process.platform === "darwin") { + const apps = MACOS_TERMINALS.map((a) => `"${a}"`).join(", "); + script = [ + `osascript <<'APPLESCRIPT'`, + `delay ${delaySec}`, + `tell application "System Events"`, + ` repeat with appName in {${apps}}`, + ` if exists (application process (appName as string)) then`, + ` set frontmost of application process (appName as string) to true`, + ` delay 0.05`, + ` keystroke "1"`, + ` key code 36`, + ` exit repeat`, + ` end if`, + ` end repeat`, + `end tell`, + `APPLESCRIPT`, + ].join("\n"); } - if (process.platform === "darwin") { - const script = buildOsascriptScript(delayMs); - const child = Bun.spawn(["bash", "-c", script], { - detached: true, - stdio: ["ignore", "ignore", "ignore"], - }); - child.unref(); - return; - } + if (!script) return; // Linux/Windows without tmux: user must press 1 manually - // Linux/Windows without tmux: no-op; user must press 1 manually. + const child = Bun.spawn(["bash", "-c", script], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }); + child.unref(); } From cdd0e834418ba0ca711e7f9c52c8c2e50f9ad984 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Mon, 11 May 2026 13:13:24 +0700 Subject: [PATCH 06/39] fix(hook): detect WarpTerminal via bundle name, not process name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warp ships as Warp.app/Contents/MacOS/stable, so its macOS process name is "stable" — not "warp". The previous code checked `exists (application process "warp")` via System Events, which always returned false on Warp, leaving the keystroke never injected and the CC plan-accept TUI unattended. Fix: check `application "Warp" is running` (bundle-name lookup, works regardless of the binary name) and activate Warp directly before sending keystrokes. Fall through to process-name search for iTerm2 and Terminal unchanged. Test: update the osascript assertion to match the new Warp check. 7/7 tests pass. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- apps/hook/server/keystrokeInjector.test.ts | 2 +- apps/hook/server/keystrokeInjector.ts | 37 ++++++++++++++-------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts index d3cc9b94d..3a43ae94d 100644 --- a/apps/hook/server/keystrokeInjector.test.ts +++ b/apps/hook/server/keystrokeInjector.test.ts @@ -62,7 +62,7 @@ describe("spawnKeystrokeInjector", () => { expect(spawnCalls).toHaveLength(1); const script = spawnCalls[0].cmd[2] as string; expect(script).toContain("osascript"); - expect(script).toContain("warp"); + expect(script).toContain('application "Warp" is running'); expect(script).toContain("iTerm2"); expect(script).toContain("Terminal"); expect(script).toContain('keystroke "1"'); diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts index 907ffebd7..1db86ab95 100644 --- a/apps/hook/server/keystrokeInjector.ts +++ b/apps/hook/server/keystrokeInjector.ts @@ -1,5 +1,5 @@ // Injects "1\n" into CC terminal to auto-select the plan-accept "clear + bypass" option. -const MACOS_TERMINALS = ["warp", "iTerm2", "Terminal"]; +const MACOS_PROCESS_TERMINALS = ["iTerm2", "Terminal"]; export function spawnKeystrokeInjector(delayMs = 600): void { const delaySec = (delayMs / 1000).toFixed(2); @@ -10,21 +10,32 @@ export function spawnKeystrokeInjector(delayMs = 600): void { if (tmuxPane) { script = `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(tmuxPane)} 1 Enter`; } else if (process.platform === "darwin") { - const apps = MACOS_TERMINALS.map((a) => `"${a}"`).join(", "); + const apps = MACOS_PROCESS_TERMINALS.map((a) => `"${a}"`).join(", "); + // Warp ships as Warp.app/MacOS/stable so its process name is "stable", not "warp". + // Check by bundle name first, then fall back to process-name search for other terminals. script = [ `osascript <<'APPLESCRIPT'`, `delay ${delaySec}`, - `tell application "System Events"`, - ` repeat with appName in {${apps}}`, - ` if exists (application process (appName as string)) then`, - ` set frontmost of application process (appName as string) to true`, - ` delay 0.05`, - ` keystroke "1"`, - ` key code 36`, - ` exit repeat`, - ` end if`, - ` end repeat`, - `end tell`, + `if application "Warp" is running then`, + ` tell application "Warp" to activate`, + ` delay 0.05`, + ` tell application "System Events"`, + ` keystroke "1"`, + ` key code 36`, + ` end tell`, + `else`, + ` tell application "System Events"`, + ` repeat with appName in {${apps}}`, + ` if exists (application process (appName as string)) then`, + ` set frontmost of application process (appName as string) to true`, + ` delay 0.05`, + ` keystroke "1"`, + ` key code 36`, + ` exit repeat`, + ` end if`, + ` end repeat`, + ` end tell`, + `end if`, `APPLESCRIPT`, ].join("\n"); } From 08261185fcbee8f4a458e29125785155ee43ca3c Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Mon, 11 May 2026 18:23:49 +0700 Subject: [PATCH 07/39] Keep plan approvals in the current session by default Constraint: Approved Ralph plan required saved bypassPermissionsClearReminder to nudge /clear without native fresh-thread deferral.\nRejected: Reusing native clear as the default | it can restart or open a fresh thread unexpectedly.\nConfidence: high\nScope-risk: moderate\nDirective: Treat deferToNativeForClear as an explicit native/fresh-thread escape hatch only; do not wire it to saved reminder mode.\nTested: bun test packages/editor/approvalBody.test.ts apps/hook/server/keystrokeInjector.test.ts; git diff --check scoped files; bun run typecheck; bun test; bun run build:hook; architect verification approved.\nNot-tested: Interactive manual browser smoke of Claude Code native dialog selection. --- apps/hook/server/index.ts | 6 ++- apps/hook/server/keystrokeInjector.test.ts | 21 ++++++++- apps/hook/server/keystrokeInjector.ts | 6 ++- packages/editor/approvalBody.test.ts | 51 +++++++++++++++++++--- packages/editor/approvalBody.ts | 14 +++++- packages/ui/utils/permissionMode.ts | 6 +-- 6 files changed, 90 insertions(+), 14 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 90ceba02b..66e377816 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -144,7 +144,7 @@ import { isVersionInvocation, } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; -import { spawnKeystrokeInjector } from "./keystrokeInjector"; +import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; import { tmpdir } from "os"; @@ -1525,7 +1525,9 @@ if (args[0] === "sessions") { ) { const nativeClearEnabled = await ensureClearContextSettingEnabled(); if (nativeClearEnabled) { - spawnKeystrokeInjector(); + if (shouldAutoSelectNativeClear()) { + spawnKeystrokeInjector(); + } process.exit(0); } result.clearContextNudge = true; diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts index 3a43ae94d..5391a6d94 100644 --- a/apps/hook/server/keystrokeInjector.test.ts +++ b/apps/hook/server/keystrokeInjector.test.ts @@ -7,7 +7,26 @@ import { beforeEach, afterEach, } from "bun:test"; -import { spawnKeystrokeInjector } from "./keystrokeInjector"; +import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; + +describe("shouldAutoSelectNativeClear", () => { + it("defaults to false", () => { + expect(shouldAutoSelectNativeClear({} as NodeJS.ProcessEnv)).toBe(false); + }); + + it("is true only for PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR=1", () => { + expect( + shouldAutoSelectNativeClear({ + PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "1", + } as NodeJS.ProcessEnv), + ).toBe(true); + expect( + shouldAutoSelectNativeClear({ + PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "true", + } as NodeJS.ProcessEnv), + ).toBe(false); + }); +}); describe("spawnKeystrokeInjector", () => { let spawnCalls: { cmd: string[]; opts: Record }[] = []; diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts index 1db86ab95..81bc816f8 100644 --- a/apps/hook/server/keystrokeInjector.ts +++ b/apps/hook/server/keystrokeInjector.ts @@ -1,6 +1,10 @@ -// Injects "1\n" into CC terminal to auto-select the plan-accept "clear + bypass" option. +// Opt-in helper that injects "1\n" into CC terminal to auto-select native clear + bypass. const MACOS_PROCESS_TERMINALS = ["iTerm2", "Terminal"]; +export function shouldAutoSelectNativeClear(env: NodeJS.ProcessEnv = process.env): boolean { + return env["PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR"] === "1"; +} + export function spawnKeystrokeInjector(delayMs = 600): void { const delaySec = (delayMs / 1000).toFixed(2); const tmuxPane = process.env["TMUX_PANE"]; diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts index 3d1977074..4c6b974c3 100644 --- a/packages/editor/approvalBody.test.ts +++ b/packages/editor/approvalBody.test.ts @@ -1,8 +1,30 @@ import { describe, expect, test } from 'bun:test'; -import { buildApprovalRequestBody } from './approvalBody'; +import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove } from './approvalBody'; + +describe('shouldEnableNativeClearBeforeApprove', () => { + test('enables native clear only for explicit Claude Code ExitPlanMode overrides', () => { + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + toolName: 'ExitPlanMode', + override: { deferToNativeForClear: true }, + })).toBe(true); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + toolName: 'ExitPlanMode', + override: { permissionMode: 'bypassPermissionsClearReminder' }, + })).toBe(false); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + toolName: 'OtherTool', + override: { deferToNativeForClear: true }, + })).toBe(false); + }); +}); describe('buildApprovalRequestBody', () => { - test('maps bypass clear reminder mode to native Claude Code clear on ExitPlanMode', () => { + test('maps bypass clear reminder mode to reminder fallback on ExitPlanMode', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'bypassPermissionsClearReminder', @@ -10,7 +32,7 @@ describe('buildApprovalRequestBody', () => { planSaveSettings: { enabled: true }, })).toEqual({ permissionMode: 'bypassPermissions', - deferToNativeForClear: true, + clearContextNudge: true, planSave: { enabled: true }, }); }); @@ -60,7 +82,7 @@ describe('buildApprovalRequestBody', () => { }); }); - test('uses native clear for bypass clear reminder override when ExitPlanMode is known', () => { + test('keeps bypass clear reminder override as reminder fallback when ExitPlanMode is known', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'acceptEdits', @@ -71,7 +93,7 @@ describe('buildApprovalRequestBody', () => { planSaveSettings: { enabled: true }, })).toEqual({ permissionMode: 'bypassPermissions', - deferToNativeForClear: true, + clearContextNudge: true, planSave: { enabled: true }, }); }); @@ -100,10 +122,11 @@ describe('buildApprovalRequestBody', () => { }); }); - test('forwards deferToNativeForClear for Claude Code bypass approvals', () => { + test('forwards deferToNativeForClear only for explicit Claude Code ExitPlanMode bypass approvals', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'acceptEdits', + toolName: 'ExitPlanMode', override: { permissionMode: 'bypassPermissions', deferToNativeForClear: true, @@ -116,6 +139,22 @@ describe('buildApprovalRequestBody', () => { }); }); + test('does not forward deferToNativeForClear without ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + toolName: 'OtherTool', + override: { + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + planSave: { enabled: true }, + }); + }); + test('does not forward deferToNativeForClear for OpenCode approvals', () => { expect(buildApprovalRequestBody({ origin: 'opencode', diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index a5e1ba7b4..55a905fcc 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -19,6 +19,18 @@ export interface ApprovalRequestBody { deferToNativeForClear?: boolean; } +export function shouldEnableNativeClearBeforeApprove(options: { + origin: Origin | null; + toolName?: string; + override?: ApprovalOverride; +}): boolean { + return ( + options.origin === 'claude-code' && + options.toolName === 'ExitPlanMode' && + options.override?.deferToNativeForClear === true + ); +} + export function buildApprovalRequestBody(options: { origin: Origin | null; permissionMode: PermissionMode; @@ -33,7 +45,7 @@ export function buildApprovalRequestBody(options: { if (origin === 'claude-code') { const effectivePermissionMode = override.permissionMode ?? permissionMode; const wantsClearContext = effectivePermissionMode === 'bypassPermissionsClearReminder'; - const useNativeClear = override.deferToNativeForClear || (wantsClearContext && toolName === 'ExitPlanMode'); + const useNativeClear = shouldEnableNativeClearBeforeApprove({ origin, toolName, override }); body.permissionMode = wantsClearContext ? 'bypassPermissions' : effectivePermissionMode; diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index ed72dc4f1..fa016be2a 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,7 +6,7 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls - * - bypassPermissionsClearReminder: Persisted UI mode that uses native clear-on-accept for Claude Code plan approvals and a /clear reminder fallback otherwise + * - bypassPermissionsClearReminder: Persisted UI mode that bypasses permissions and emits a /clear reminder after plan approval * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -36,8 +36,8 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de }, { value: 'bypassPermissionsClearReminder', - label: 'Bypass + Clear Context', - description: 'For Claude Code plan approvals, defer to the native clear-context flow and bypass permissions; otherwise emit a /clear reminder.', + label: 'Bypass + /clear Reminder', + description: 'Bypass permissions after plan approval and emit a /clear reminder without invoking the native fresh-thread flow.', }, { value: 'default', From 78243feb653d9b72f39e9dee75caa515300c954b Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 15:58:06 +0700 Subject: [PATCH 08/39] omx(team): auto-checkpoint worker-4 [unknown] --- apps/hook/server/index.ts | 500 ++++++--------------- apps/hook/server/keystrokeInjector.test.ts | 126 ------ apps/hook/server/keystrokeInjector.ts | 54 --- 3 files changed, 126 insertions(+), 554 deletions(-) delete mode 100644 apps/hook/server/keystrokeInjector.test.ts delete mode 100644 apps/hook/server/keystrokeInjector.ts diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 66e377816..1bbdb6c7f 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -52,7 +52,10 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { startPlannotatorServer, handleServerReady } from "@plannotator/server"; +import { + startPlannotatorServer, + handleServerReady, +} from "@plannotator/server"; import { startReviewServer, handleReviewServerReady, @@ -66,36 +69,12 @@ import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator import { parseReviewArgs } from "@plannotator/shared/review-args"; import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-reference"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; -import { - urlToMarkdown, - isConvertedSource, -} from "@plannotator/shared/url-to-markdown"; -import { - fetchRef, - createWorktree, - removeWorktree, - ensureObjectAvailable, -} from "@plannotator/shared/worktree"; -import { - createWorktreePool, - type WorktreePool, -} from "@plannotator/shared/worktree-pool"; -import { - parsePRUrl, - checkPRAuth, - fetchPR, - getCliName, - getCliInstallUrl, - getMRLabel, - getMRNumberLabel, - getDisplayRepo, -} from "@plannotator/server/pr"; +import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown"; +import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree"; +import { createWorktreePool, type WorktreePool } from "@plannotator/shared/worktree-pool"; +import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; -import { - resolveMarkdownFile, - resolveUserPath, - hasMarkdownFiles, -} from "@plannotator/shared/resolve-file"; +import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; @@ -106,11 +85,7 @@ import { getPlanToolName, buildPlanFileRule, } from "@plannotator/shared/prompts"; -import { - registerSession, - unregisterSession, - listSessions, -} from "@plannotator/server/sessions"; +import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions"; import { openBrowser } from "@plannotator/server/browser"; import { detectProjectName } from "@plannotator/server/project"; import { hostnameOrFallback } from "@plannotator/shared/project"; @@ -125,16 +100,8 @@ import { resolveSessionLogByCwdScan, type RenderedMessage, } from "./session-log"; -import { - findCodexRolloutByThreadId, - getLastCodexMessage, - getLatestCodexPlan, -} from "./codex-session"; -import { - findCopilotPlanContent, - findCopilotSessionForCwd, - getLastCopilotMessage, -} from "./copilot-session"; +import { findCodexRolloutByThreadId, getLastCodexMessage, getLatestCodexPlan } from "./codex-session"; +import { findCopilotPlanContent, findCopilotSessionForCwd, getLastCopilotMessage } from "./copilot-session"; import { formatInteractiveNoArgClarification, formatTopLevelHelp, @@ -144,7 +111,6 @@ import { isVersionInvocation, } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; -import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; import { tmpdir } from "os"; @@ -218,9 +184,7 @@ function emitAnnotateOutcome(result: { if (hookFlag) { if (result.approved || result.exit) return; if (result.feedback) { - console.log( - JSON.stringify({ decision: "block", reason: result.feedback }), - ); + console.log(JSON.stringify({ decision: "block", reason: result.feedback })); } return; } @@ -230,12 +194,7 @@ function emitAnnotateOutcome(result: { } else if (result.exit) { console.log(JSON.stringify({ decision: "dismissed" })); } else { - console.log( - JSON.stringify({ - decision: "annotated", - feedback: result.feedback || "", - }), - ); + console.log(JSON.stringify({ decision: "annotated", feedback: result.feedback || "" })); } return; } @@ -287,17 +246,12 @@ const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined; // packages/shared/agents.ts (see header comment there). const originOverride = process.env.PLANNOTATOR_ORIGIN as Origin | undefined; const detectedOrigin: Origin = - originOverride && originOverride in AGENT_CONFIG - ? originOverride - : process.env.CODEX_THREAD_ID - ? "codex" - : process.env.COPILOT_CLI - ? "copilot-cli" - : process.env.OPENCODE - ? "opencode" - : process.env.GEMINI_CLI - ? "gemini-cli" - : "claude-code"; + (originOverride && originOverride in AGENT_CONFIG) ? originOverride : + process.env.CODEX_THREAD_ID ? "codex" : + process.env.COPILOT_CLI ? "copilot-cli" : + process.env.OPENCODE ? "opencode" : + process.env.GEMINI_CLI ? "gemini-cli" : + "claude-code"; if (args[0] === "sessions") { // ============================================ @@ -307,9 +261,7 @@ if (args[0] === "sessions") { if (args.includes("--clean")) { // Force cleanup: list sessions (which auto-removes stale entries) const sessions = listSessions(); - console.error( - `Cleaned up stale sessions. ${sessions.length} active session(s) remain.`, - ); + console.error(`Cleaned up stale sessions. ${sessions.length} active session(s) remain.`); process.exit(0); } @@ -327,9 +279,7 @@ if (args[0] === "sessions") { const n = nArg ? parseInt(nArg, 10) : 1; const session = sessions[n - 1]; if (!session) { - console.error( - `Session #${n} not found. ${sessions.length} active session(s).`, - ); + console.error(`Session #${n} not found. ${sessions.length} active session(s).`); process.exit(1); } await openBrowser(session.url); @@ -341,17 +291,13 @@ if (args[0] === "sessions") { console.error("Active Plannotator sessions:\n"); for (let i = 0; i < sessions.length; i++) { const s = sessions[i]; - const age = Math.round( - (Date.now() - new Date(s.startedAt).getTime()) / 60000, - ); - const ageStr = - age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`; - console.error( - ` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`, - ); + const age = Math.round((Date.now() - new Date(s.startedAt).getTime()) / 60000); + const ageStr = age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`; + console.error(` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`); } console.error(`\nReopen with: plannotator sessions --open [N]`); process.exit(0); + } else if (args[0] === "review") { // ============================================ // CODE REVIEW MODE @@ -379,9 +325,7 @@ if (args[0] === "sessions") { console.error(`Invalid PR/MR URL: ${urlArg}`); console.error("Supported formats:"); console.error(" GitHub: https://github.com/owner/repo/pull/123"); - console.error( - " GitLab: https://gitlab.com/group/project/-/merge_requests/42", - ); + console.error(" GitLab: https://gitlab.com/group/project/-/merge_requests/42"); process.exit(1); } @@ -393,9 +337,7 @@ if (args[0] === "sessions") { } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("not found") || msg.includes("ENOENT")) { - console.error( - `${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`, - ); + console.error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`); console.error(`Install it from ${cliUrl}`); } else { console.error(msg); @@ -403,9 +345,7 @@ if (args[0] === "sessions") { process.exit(1); } - console.error( - `Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`, - ); + console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`); try { const pr = await fetchPR(prRef); rawPatch = pr.rawPatch; @@ -423,68 +363,42 @@ if (args[0] === "sessions") { let sessionDir: string | undefined; try { const repoDir = process.cwd(); - const identifier = - prMetadata.platform === "github" - ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` - : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; + const identifier = prMetadata.platform === "github" + ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` + : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; const suffix = Math.random().toString(36).slice(2, 8); // Resolve tmpdir to its real path — on macOS, tmpdir() returns /var/folders/... // but processes report /private/var/folders/... which breaks path stripping. - sessionDir = path.join( - realpathSync(tmpdir()), - `plannotator-pr-${identifier}-${suffix}`, - ); - const prNumber = - prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; + sessionDir = path.join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`); + const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; localPath = path.join(sessionDir, "pool", `pr-${prNumber}`); - const fetchRefStr = - prMetadata.platform === "github" - ? `refs/pull/${prMetadata.number}/head` - : `refs/merge-requests/${prMetadata.iid}/head`; + const fetchRefStr = prMetadata.platform === "github" + ? `refs/pull/${prMetadata.number}/head` + : `refs/merge-requests/${prMetadata.iid}/head`; // Validate inputs from platform API to prevent git flag/path injection - if ( - prMetadata.baseBranch.includes("..") || - prMetadata.baseBranch.startsWith("-") - ) - throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); - if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) - throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); + if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); + if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); // Detect same-repo vs cross-repo (must match both owner/repo AND host) let isSameRepo = false; try { - const remoteResult = await gitRuntime.runGit([ - "remote", - "get-url", - "origin", - ]); + const remoteResult = await gitRuntime.runGit(["remote", "get-url", "origin"]); if (remoteResult.exitCode === 0) { const remoteUrl = remoteResult.stdout.trim(); const currentRepo = parseRemoteUrl(remoteUrl); - const prRepo = - prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; - const repoMatches = - !!currentRepo && - currentRepo.toLowerCase() === prRepo.toLowerCase(); + const prRepo = prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; + const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase(); // Extract host from remote URL to avoid cross-instance false positives (GHE) const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1]; - const httpsHost = (() => { - try { - return new URL(remoteUrl).hostname; - } catch { - return null; - } - })(); + const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })(); const remoteHost = (sshHost || httpsHost || "").toLowerCase(); const prHost = prMetadata.host.toLowerCase(); isSameRepo = repoMatches && remoteHost === prHost; } - } catch { - /* not in a git repo — cross-repo path */ - } + } catch { /* not in a git repo — cross-repo path */ } if (isSameRepo) { // ── Same-repo: fast worktree path ── @@ -494,9 +408,7 @@ if (args[0] === "sessions") { // Both MUST happen before the PR head fetch since FETCH_HEAD is what // createWorktree uses — the PR head fetch must be last. await fetchRef(gitRuntime, prMetadata.baseBranch, { cwd: repoDir }); - await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { - cwd: repoDir, - }); + await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { cwd: repoDir }); // Fetch PR head LAST — sets FETCH_HEAD to the PR tip for createWorktree. await fetchRef(gitRuntime, fetchRefStr, { cwd: repoDir }); @@ -509,65 +421,41 @@ if (args[0] === "sessions") { worktreeCleanup = async () => { if (worktreePool) await worktreePool.cleanup(gitRuntime); - try { - rmSync(sessionDir, { recursive: true, force: true }); - } catch {} + try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} }; process.once("exit", () => { // Best-effort sync cleanup: remove each pool worktree from git, then rm session dir try { for (const entry of worktreePool?.entries() ?? []) { - Bun.spawnSync( - ["git", "worktree", "remove", "--force", entry.path], - { cwd: repoDir }, - ); + Bun.spawnSync(["git", "worktree", "remove", "--force", entry.path], { cwd: repoDir }); } } catch {} - try { - Bun.spawnSync(["rm", "-rf", sessionDir]); - } catch {} + try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} }); } else { // ── Cross-repo: shallow clone + fetch PR head ── - const prRepo = - prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; + const prRepo = prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; // Validate repo identifier to prevent flag injection via crafted URLs - if (/^-/.test(prRepo)) - throw new Error(`Invalid repository identifier: ${prRepo}`); + if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`); const cli = prMetadata.platform === "github" ? "gh" : "glab"; const host = prMetadata.host; // gh/glab repo clone doesn't accept --hostname; set GH_HOST/GITLAB_HOST env instead const isDefaultHost = host === "github.com" || host === "gitlab.com"; - const cloneEnv = isDefaultHost - ? undefined - : { - ...process.env, - ...(prMetadata.platform === "github" - ? { GH_HOST: host } - : { GITLAB_HOST: host }), - }; + const cloneEnv = isDefaultHost ? undefined : { + ...process.env, + ...(prMetadata.platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }), + }; // Step 1: Fast skeleton clone (no checkout, depth 1 — minimal data transfer) console.error(`Cloning ${prRepo} (shallow)...`); const cloneResult = Bun.spawnSync( - [ - cli, - "repo", - "clone", - prRepo, - localPath, - "--", - "--depth=1", - "--no-checkout", - ], + [cli, "repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], { stderr: "pipe", env: cloneEnv }, ); if (cloneResult.exitCode !== 0) { - throw new Error( - `${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`, - ); + throw new Error(`${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`); } // Step 2: Fetch only the PR head ref (targeted, much faster than full fetch) @@ -576,54 +464,23 @@ if (args[0] === "sessions") { ["git", "fetch", "--depth=200", "origin", fetchRefStr], { cwd: localPath, stderr: "pipe" }, ); - if (fetchResult.exitCode !== 0) - throw new Error( - `Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`, - ); + if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`); // Step 3: Checkout PR head (critical — if this fails, worktree is empty) - const checkoutResult = Bun.spawnSync( - ["git", "checkout", "FETCH_HEAD"], - { cwd: localPath, stderr: "pipe" }, - ); + const checkoutResult = Bun.spawnSync(["git", "checkout", "FETCH_HEAD"], { cwd: localPath, stderr: "pipe" }); if (checkoutResult.exitCode !== 0) { - throw new Error( - `git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`, - ); + throw new Error(`git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`); } // Best-effort: create base refs so `git diff main...HEAD` and `git diff origin/main...HEAD` work - const baseFetch = Bun.spawnSync( - ["git", "fetch", "--depth=200", "origin", prMetadata.baseSha], - { cwd: localPath, stderr: "pipe" }, - ); - if (baseFetch.exitCode !== 0) - console.error( - "Warning: failed to fetch baseSha, agent diffs may be inaccurate", - ); - Bun.spawnSync( - ["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], - { cwd: localPath, stderr: "pipe" }, - ); - Bun.spawnSync( - [ - "git", - "update-ref", - `refs/remotes/origin/${prMetadata.baseBranch}`, - prMetadata.baseSha, - ], - { cwd: localPath, stderr: "pipe" }, - ); + const baseFetch = Bun.spawnSync(["git", "fetch", "--depth=200", "origin", prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); + if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate"); + Bun.spawnSync(["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); + Bun.spawnSync(["git", "update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); - worktreeCleanup = () => { - try { - rmSync(sessionDir, { recursive: true, force: true }); - } catch {} - }; + worktreeCleanup = () => { try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} }; process.once("exit", () => { - try { - Bun.spawnSync(["rm", "-rf", sessionDir]); - } catch {} + try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} }); } @@ -634,22 +491,14 @@ if (args[0] === "sessions") { // Create worktree pool with the initial PR as the first entry worktreePool = createWorktreePool( { sessionDir, repoDir, isSameRepo }, - { - path: localPath, - prUrl: prMetadata.url, - number: prNumber, - ready: true, - }, + { path: localPath, prUrl: prMetadata.url, number: prNumber, ready: true }, ); console.error(`Local checkout ready at ${localPath}`); } catch (err) { console.error(`Warning: --local failed, falling back to remote diff`); console.error(err instanceof Error ? err.message : String(err)); - if (sessionDir) - try { - rmSync(sessionDir, { recursive: true, force: true }); - } catch {} + if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} agentCwd = undefined; worktreePool = undefined; worktreeCleanup = undefined; @@ -691,12 +540,7 @@ if (args[0] === "sessions") { handleReviewServerReady(url, isRemote, port); if (isRemote && sharingEnabled && rawPatch) { - await writeRemoteShareLink( - rawPatch, - shareBaseUrl, - "review changes", - "diff only", - ).catch(() => {}); + await writeRemoteShareLink(rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => {}); } }, }); @@ -708,9 +552,7 @@ if (args[0] === "sessions") { mode: "review", project: reviewProject, startedAt: new Date().toISOString(), - label: isPRMode - ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` - : `review-${reviewProject}`, + label: isPRMode ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` : `review-${reviewProject}`, }); // Wait for user feedback @@ -734,6 +576,7 @@ if (args[0] === "sessions") { } } process.exit(0); + } else if (args[0] === "annotate") { // ============================================ // ANNOTATE MODE @@ -741,9 +584,7 @@ if (args[0] === "sessions") { const rawFilePath = args[1]; if (!rawFilePath) { - console.error( - "Usage: plannotator annotate [--no-jina] [--gate] [--json] [--hook]", - ); + console.error("Usage: plannotator annotate [--no-jina] [--gate] [--json] [--hook]"); process.exit(1); } @@ -773,46 +614,31 @@ if (args[0] === "sessions") { if (isUrl) { const useJina = resolveUseJina(cliNoJina, loadConfig()); - console.error( - `Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`, - ); + console.error(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`); try { const result = await urlToMarkdown(filePath, { useJina }); markdown = result.markdown; sourceConverted = isConvertedSource(result.source); if (process.env.PLANNOTATOR_DEBUG) { - console.error( - `[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`, - ); + console.error(`[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`); } } catch (err) { - console.error( - `Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`, - ); + console.error(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } absolutePath = filePath; // Use URL as the "path" for display - sourceInfo = filePath; // Full URL for source attribution + sourceInfo = filePath; // Full URL for source attribution } else { // Folder check with literal-@ fallback for scoped-package-style names. const folderCandidate = resolveAtReference(rawFilePath, (c) => { - try { - return statSync(resolveUserPath(c, projectRoot)).isDirectory(); - } catch { - return false; - } + try { return statSync(resolveUserPath(c, projectRoot)).isDirectory(); } + catch { return false; } }); if (folderCandidate !== null) { const resolvedArg = resolveUserPath(folderCandidate, projectRoot); // Folder annotation mode (markdown + HTML files) - if ( - !hasMarkdownFiles( - resolvedArg, - FILE_BROWSER_EXCLUDED, - /\.(mdx?|html?)$/i, - ) - ) { + if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) { console.error(`No markdown or HTML files found in ${resolvedArg}`); process.exit(1); } @@ -832,9 +658,7 @@ if (args[0] === "sessions") { const resolvedArg = resolveUserPath(htmlCandidate, projectRoot); const htmlFile = Bun.file(resolvedArg); if (htmlFile.size > 10 * 1024 * 1024) { - console.error( - `File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`, - ); + console.error(`File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`); process.exit(1); } const html = await htmlFile.text(); @@ -857,9 +681,7 @@ if (args[0] === "sessions") { } if (resolved.kind === "ambiguous") { - console.error( - `Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`, - ); + console.error(`Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`); for (const match of resolved.matches) { console.error(` ${match}`); } @@ -899,12 +721,7 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled && markdown) { - await writeRemoteShareLink( - markdown, - shareBaseUrl, - "annotate", - "document only", - ).catch(() => {}); + await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {}); } }, }); @@ -933,6 +750,7 @@ if (args[0] === "sessions") { // Output feedback (captured by slash command) emitAnnotateOutcome(result); process.exit(0); + } else if (args[0] === "annotate-last" || args[0] === "last") { // ============================================ // ANNOTATE LAST MESSAGE MODE @@ -956,11 +774,7 @@ if (args[0] === "sessions") { } const msg = getLastCodexMessage(rolloutPath); if (msg) { - lastMessage = { - messageId: codexThreadId, - text: msg.text, - lineNumbers: [], - }; + lastMessage = { messageId: codexThreadId, text: msg.text, lineNumbers: [] }; } } } else { @@ -990,9 +804,7 @@ if (args[0] === "sessions") { if (lastMessage) return; const paths = getPaths(); if (process.env.PLANNOTATOR_DEBUG) { - console.error( - `[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`, - ); + console.error(`[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`); } for (const logPath of paths) { lastMessage = getLastRenderedMessage(logPath); @@ -1002,25 +814,17 @@ if (args[0] === "sessions") { // 1. Walk ancestor PIDs for a matching session metadata file const ancestorLog = resolveSessionLogByAncestorPids(); - tryLogCandidates("Ancestor PID session metadata", () => - ancestorLog ? [ancestorLog] : [], - ); + tryLogCandidates("Ancestor PID session metadata", () => ancestorLog ? [ancestorLog] : []); // 2. Scan all session metadata files for one whose cwd matches const cwdScanLog = resolveSessionLogByCwdScan({ cwd: projectRoot }); - tryLogCandidates("Cwd-scan session metadata", () => - cwdScanLog ? [cwdScanLog] : [], - ); + tryLogCandidates("Cwd-scan session metadata", () => cwdScanLog ? [cwdScanLog] : []); // 3. Fall back to CWD slug match (mtime-based) - tryLogCandidates("CWD slug match (mtime)", () => - findSessionLogsForCwd(projectRoot), - ); + tryLogCandidates("CWD slug match (mtime)", () => findSessionLogsForCwd(projectRoot)); // 4. Fall back to ancestor directory walk - tryLogCandidates("Directory ancestor walk", () => - findSessionLogsByAncestorWalk(projectRoot), - ); + tryLogCandidates("Directory ancestor walk", () => findSessionLogsByAncestorWalk(projectRoot)); } if (!lastMessage) { @@ -1029,9 +833,7 @@ if (args[0] === "sessions") { } if (process.env.PLANNOTATOR_DEBUG) { - console.error( - `[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`, - ); + console.error(`[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`); } const annotateProject = (await detectProjectName()) ?? "_unknown"; @@ -1050,12 +852,7 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink( - lastMessage.text, - shareBaseUrl, - "annotate", - "message only", - ).catch(() => {}); + await writeRemoteShareLink(lastMessage.text, shareBaseUrl, "annotate", "message only").catch(() => {}); } }, }); @@ -1078,6 +875,7 @@ if (args[0] === "sessions") { emitAnnotateOutcome(result); process.exit(0); + } else if (args[0] === "archive") { // ============================================ // ARCHIVE BROWSER MODE @@ -1112,6 +910,7 @@ if (args[0] === "sessions") { await Bun.sleep(500); server.stop(); process.exit(0); + } else if (args[0] === "copilot-plan") { // ============================================ // COPILOT CLI PLAN INTERCEPTION MODE @@ -1122,13 +921,7 @@ if (args[0] === "sessions") { // No output = allow the tool call to proceed. const eventJson = await Bun.stdin.text(); - let event: { - toolName: string; - toolArgs: string; - cwd: string; - timestamp: number; - sessionId?: string; - }; + let event: { toolName: string; toolArgs: string; cwd: string; timestamp: number; sessionId?: string }; try { event = JSON.parse(eventJson); @@ -1163,12 +956,7 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink( - planContent, - shareBaseUrl, - "review the plan", - "plan only", - ).catch(() => {}); + await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); } }, }); @@ -1189,26 +977,23 @@ if (args[0] === "sessions") { // Output Copilot CLI permission decision format if (result.approved) { - console.log( - JSON.stringify({ - permissionDecision: "allow", - }), - ); + console.log(JSON.stringify({ + permissionDecision: "allow", + })); } else { const feedback = getPlanDeniedPrompt("copilot-cli", undefined, { toolName: getPlanToolName("copilot-cli"), planFileRule: "", feedback: result.feedback || "Plan changes requested", }); - console.log( - JSON.stringify({ - permissionDecision: "deny", - permissionDecisionReason: feedback, - }), - ); + console.log(JSON.stringify({ + permissionDecision: "deny", + permissionDecisionReason: feedback, + })); } process.exit(0); + } else if (args[0] === "copilot-last") { // ============================================ // COPILOT CLI ANNOTATE LAST MESSAGE MODE @@ -1217,9 +1002,7 @@ if (args[0] === "sessions") { const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); if (process.env.PLANNOTATOR_DEBUG) { - console.error( - `[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`, - ); + console.error(`[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`); } const sessionDir = findCopilotSessionForCwd(projectRoot); @@ -1258,12 +1041,7 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink( - msg.text, - shareBaseUrl, - "annotate", - "message only", - ).catch(() => {}); + await writeRemoteShareLink(msg.text, shareBaseUrl, "annotate", "message only").catch(() => {}); } }, }); @@ -1284,6 +1062,7 @@ if (args[0] === "sessions") { emitAnnotateOutcome(result); process.exit(0); + } else if (args[0] === "improve-context") { // ============================================ // IMPROVEMENT HOOK CONTEXT INJECTION MODE @@ -1306,16 +1085,15 @@ if (args[0] === "sessions") { if (context === null) process.exit(0); - console.log( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PreToolUse", - additionalContext: context, - }, - }), - ); + console.log(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: context, + }, + })); process.exit(0); + } else { // ============================================ // PLAN REVIEW MODE (default) @@ -1367,12 +1145,7 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink( - latestPlan.text, - shareBaseUrl, - "review the plan", - "plan only", - ).catch(() => {}); + await writeRemoteShareLink(latestPlan.text, shareBaseUrl, "review the plan", "plan only").catch(() => {}); } }, }); @@ -1402,7 +1175,7 @@ if (args[0] === "sessions") { planFileRule: "", feedback: result.feedback || "Plan changes requested", }), - }), + }) ); } @@ -1415,8 +1188,7 @@ if (args[0] === "sessions") { let planFilename = ""; // Detect harness: Gemini sends plan_filename (file on disk), Claude Code sends plan (inline) - planFilename = - event.tool_input?.plan_filename || event.tool_input?.plan_path || ""; + planFilename = event.tool_input?.plan_filename || event.tool_input?.plan_path || ""; isGemini = !!planFilename; if (isGemini) { @@ -1424,12 +1196,7 @@ if (args[0] === "sessions") { // transcript_path = /chats/session-...json // plan lives at = //plans/ const projectTempDir = path.dirname(path.dirname(event.transcript_path)); - const planFilePath = path.join( - projectTempDir, - event.session_id, - "plans", - planFilename, - ); + const planFilePath = path.join(projectTempDir, event.session_id, "plans", planFilename); planContent = await Bun.file(planFilePath).text(); } else { planContent = event.tool_input?.plan || ""; @@ -1464,12 +1231,7 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink( - planContent, - shareBaseUrl, - "review the plan", - "plan only", - ).catch(() => {}); + await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); } }, }); @@ -1496,24 +1258,17 @@ if (args[0] === "sessions") { // Output decision in the appropriate format for the harness if (isGemini) { if (result.approved) { - console.log( - result.feedback - ? JSON.stringify({ systemMessage: result.feedback }) - : "{}", - ); + console.log(result.feedback ? JSON.stringify({ systemMessage: result.feedback }) : "{}"); } else { console.log( JSON.stringify({ decision: "deny", reason: getPlanDeniedPrompt("gemini-cli", undefined, { toolName: getPlanToolName("gemini-cli"), - planFileRule: buildPlanFileRule( - getPlanToolName("gemini-cli"), - planFilename, - ), + planFileRule: buildPlanFileRule(getPlanToolName("gemini-cli"), planFilename), feedback: result.feedback || "Plan changes requested", }), - }), + }) ); } } else { @@ -1525,9 +1280,6 @@ if (args[0] === "sessions") { ) { const nativeClearEnabled = await ensureClearContextSettingEnabled(); if (nativeClearEnabled) { - if (shouldAutoSelectNativeClear()) { - spawnKeystrokeInjector(); - } process.exit(0); } result.clearContextNudge = true; @@ -1557,7 +1309,7 @@ if (args[0] === "sessions") { ...(updatedPermissions.length > 0 && { updatedPermissions }), }, }, - }), + }) ); } else { console.log( @@ -1573,7 +1325,7 @@ if (args[0] === "sessions") { }), }, }, - }), + }) ); } } diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts deleted file mode 100644 index 5391a6d94..000000000 --- a/apps/hook/server/keystrokeInjector.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - describe, - it, - expect, - spyOn, - mock, - beforeEach, - afterEach, -} from "bun:test"; -import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; - -describe("shouldAutoSelectNativeClear", () => { - it("defaults to false", () => { - expect(shouldAutoSelectNativeClear({} as NodeJS.ProcessEnv)).toBe(false); - }); - - it("is true only for PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR=1", () => { - expect( - shouldAutoSelectNativeClear({ - PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "1", - } as NodeJS.ProcessEnv), - ).toBe(true); - expect( - shouldAutoSelectNativeClear({ - PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "true", - } as NodeJS.ProcessEnv), - ).toBe(false); - }); -}); - -describe("spawnKeystrokeInjector", () => { - let spawnCalls: { cmd: string[]; opts: Record }[] = []; - let originalEnv: NodeJS.ProcessEnv; - let originalPlatform: string; - - function setPlatform(v: string) { - Object.defineProperty(process, "platform", { value: v, writable: true }); - } - - beforeEach(() => { - spawnCalls = []; - originalEnv = { ...process.env }; - originalPlatform = process.platform; - delete process.env["TMUX_PANE"]; - - const mockChild = { unref: mock(() => {}) }; - spyOn(Bun, "spawn").mockImplementation( - (cmd: string[], opts: Record) => { - spawnCalls.push({ cmd, opts }); - return mockChild as ReturnType; - }, - ); - }); - - afterEach(() => { - process.env = originalEnv; - Object.defineProperty(process, "platform", { - value: originalPlatform, - writable: true, - }); - mock.restore(); - }); - - it("uses tmux send-keys when TMUX_PANE is set", () => { - process.env["TMUX_PANE"] = "%3"; - spawnKeystrokeInjector(100); - - expect(spawnCalls).toHaveLength(1); - expect(spawnCalls[0].cmd[0]).toBe("bash"); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("tmux send-keys"); - expect(script).toContain("%3"); - expect(script).toContain("1 Enter"); - expect(script).not.toContain("osascript"); - }); - - it("uses osascript on macOS when no tmux pane", () => { - setPlatform("darwin"); - spawnKeystrokeInjector(100); - - expect(spawnCalls).toHaveLength(1); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("osascript"); - expect(script).toContain('application "Warp" is running'); - expect(script).toContain("iTerm2"); - expect(script).toContain("Terminal"); - expect(script).toContain('keystroke "1"'); - expect(script).not.toContain("tmux send-keys"); - }); - - it("no-op on linux without tmux", () => { - setPlatform("linux"); - spawnKeystrokeInjector(); - expect(spawnCalls).toHaveLength(0); - }); - - it("no-op on windows without tmux", () => { - setPlatform("win32"); - spawnKeystrokeInjector(); - expect(spawnCalls).toHaveLength(0); - }); - - it("spawn is detached and unreffed (does not block caller)", () => { - setPlatform("darwin"); - spawnKeystrokeInjector(); - - expect(spawnCalls).toHaveLength(1); - const opts = spawnCalls[0].opts as { detached: boolean; stdio: string[] }; - expect(opts.detached).toBe(true); - expect(opts.stdio).toEqual(["ignore", "ignore", "ignore"]); - }); - - it("embeds delay in tmux script via sleep", () => { - process.env["TMUX_PANE"] = "%0"; - spawnKeystrokeInjector(1200); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("sleep 1.20"); - }); - - it("embeds delay in osascript via 'delay' statement", () => { - setPlatform("darwin"); - spawnKeystrokeInjector(800); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("delay 0.80"); - }); -}); diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts deleted file mode 100644 index 81bc816f8..000000000 --- a/apps/hook/server/keystrokeInjector.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Opt-in helper that injects "1\n" into CC terminal to auto-select native clear + bypass. -const MACOS_PROCESS_TERMINALS = ["iTerm2", "Terminal"]; - -export function shouldAutoSelectNativeClear(env: NodeJS.ProcessEnv = process.env): boolean { - return env["PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR"] === "1"; -} - -export function spawnKeystrokeInjector(delayMs = 600): void { - const delaySec = (delayMs / 1000).toFixed(2); - const tmuxPane = process.env["TMUX_PANE"]; - - let script: string | null = null; - - if (tmuxPane) { - script = `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(tmuxPane)} 1 Enter`; - } else if (process.platform === "darwin") { - const apps = MACOS_PROCESS_TERMINALS.map((a) => `"${a}"`).join(", "); - // Warp ships as Warp.app/MacOS/stable so its process name is "stable", not "warp". - // Check by bundle name first, then fall back to process-name search for other terminals. - script = [ - `osascript <<'APPLESCRIPT'`, - `delay ${delaySec}`, - `if application "Warp" is running then`, - ` tell application "Warp" to activate`, - ` delay 0.05`, - ` tell application "System Events"`, - ` keystroke "1"`, - ` key code 36`, - ` end tell`, - `else`, - ` tell application "System Events"`, - ` repeat with appName in {${apps}}`, - ` if exists (application process (appName as string)) then`, - ` set frontmost of application process (appName as string) to true`, - ` delay 0.05`, - ` keystroke "1"`, - ` key code 36`, - ` exit repeat`, - ` end if`, - ` end repeat`, - ` end tell`, - `end if`, - `APPLESCRIPT`, - ].join("\n"); - } - - if (!script) return; // Linux/Windows without tmux: user must press 1 manually - - const child = Bun.spawn(["bash", "-c", script], { - detached: true, - stdio: ["ignore", "ignore", "ignore"], - }); - child.unref(); -} From ea2ad0fadbf3199ec9f83091b48a914e58a60031 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 15:58:52 +0700 Subject: [PATCH 09/39] omx(team): auto-checkpoint worker-4 [unknown] --- apps/hook/server/index.ts | 4 + apps/hook/server/keystrokeInjector.test.ts | 126 +++++++++++++++++++++ apps/hook/server/keystrokeInjector.ts | 54 +++++++++ 3 files changed, 184 insertions(+) create mode 100644 apps/hook/server/keystrokeInjector.test.ts create mode 100644 apps/hook/server/keystrokeInjector.ts diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 1bbdb6c7f..3eb91bda0 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -111,6 +111,7 @@ import { isVersionInvocation, } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; +import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; import { tmpdir } from "os"; @@ -1280,6 +1281,9 @@ if (args[0] === "sessions") { ) { const nativeClearEnabled = await ensureClearContextSettingEnabled(); if (nativeClearEnabled) { + if (shouldAutoSelectNativeClear()) { + spawnKeystrokeInjector(); + } process.exit(0); } result.clearContextNudge = true; diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts new file mode 100644 index 000000000..5391a6d94 --- /dev/null +++ b/apps/hook/server/keystrokeInjector.test.ts @@ -0,0 +1,126 @@ +import { + describe, + it, + expect, + spyOn, + mock, + beforeEach, + afterEach, +} from "bun:test"; +import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; + +describe("shouldAutoSelectNativeClear", () => { + it("defaults to false", () => { + expect(shouldAutoSelectNativeClear({} as NodeJS.ProcessEnv)).toBe(false); + }); + + it("is true only for PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR=1", () => { + expect( + shouldAutoSelectNativeClear({ + PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "1", + } as NodeJS.ProcessEnv), + ).toBe(true); + expect( + shouldAutoSelectNativeClear({ + PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "true", + } as NodeJS.ProcessEnv), + ).toBe(false); + }); +}); + +describe("spawnKeystrokeInjector", () => { + let spawnCalls: { cmd: string[]; opts: Record }[] = []; + let originalEnv: NodeJS.ProcessEnv; + let originalPlatform: string; + + function setPlatform(v: string) { + Object.defineProperty(process, "platform", { value: v, writable: true }); + } + + beforeEach(() => { + spawnCalls = []; + originalEnv = { ...process.env }; + originalPlatform = process.platform; + delete process.env["TMUX_PANE"]; + + const mockChild = { unref: mock(() => {}) }; + spyOn(Bun, "spawn").mockImplementation( + (cmd: string[], opts: Record) => { + spawnCalls.push({ cmd, opts }); + return mockChild as ReturnType; + }, + ); + }); + + afterEach(() => { + process.env = originalEnv; + Object.defineProperty(process, "platform", { + value: originalPlatform, + writable: true, + }); + mock.restore(); + }); + + it("uses tmux send-keys when TMUX_PANE is set", () => { + process.env["TMUX_PANE"] = "%3"; + spawnKeystrokeInjector(100); + + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0].cmd[0]).toBe("bash"); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("tmux send-keys"); + expect(script).toContain("%3"); + expect(script).toContain("1 Enter"); + expect(script).not.toContain("osascript"); + }); + + it("uses osascript on macOS when no tmux pane", () => { + setPlatform("darwin"); + spawnKeystrokeInjector(100); + + expect(spawnCalls).toHaveLength(1); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("osascript"); + expect(script).toContain('application "Warp" is running'); + expect(script).toContain("iTerm2"); + expect(script).toContain("Terminal"); + expect(script).toContain('keystroke "1"'); + expect(script).not.toContain("tmux send-keys"); + }); + + it("no-op on linux without tmux", () => { + setPlatform("linux"); + spawnKeystrokeInjector(); + expect(spawnCalls).toHaveLength(0); + }); + + it("no-op on windows without tmux", () => { + setPlatform("win32"); + spawnKeystrokeInjector(); + expect(spawnCalls).toHaveLength(0); + }); + + it("spawn is detached and unreffed (does not block caller)", () => { + setPlatform("darwin"); + spawnKeystrokeInjector(); + + expect(spawnCalls).toHaveLength(1); + const opts = spawnCalls[0].opts as { detached: boolean; stdio: string[] }; + expect(opts.detached).toBe(true); + expect(opts.stdio).toEqual(["ignore", "ignore", "ignore"]); + }); + + it("embeds delay in tmux script via sleep", () => { + process.env["TMUX_PANE"] = "%0"; + spawnKeystrokeInjector(1200); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("sleep 1.20"); + }); + + it("embeds delay in osascript via 'delay' statement", () => { + setPlatform("darwin"); + spawnKeystrokeInjector(800); + const script = spawnCalls[0].cmd[2] as string; + expect(script).toContain("delay 0.80"); + }); +}); diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts new file mode 100644 index 000000000..81bc816f8 --- /dev/null +++ b/apps/hook/server/keystrokeInjector.ts @@ -0,0 +1,54 @@ +// Opt-in helper that injects "1\n" into CC terminal to auto-select native clear + bypass. +const MACOS_PROCESS_TERMINALS = ["iTerm2", "Terminal"]; + +export function shouldAutoSelectNativeClear(env: NodeJS.ProcessEnv = process.env): boolean { + return env["PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR"] === "1"; +} + +export function spawnKeystrokeInjector(delayMs = 600): void { + const delaySec = (delayMs / 1000).toFixed(2); + const tmuxPane = process.env["TMUX_PANE"]; + + let script: string | null = null; + + if (tmuxPane) { + script = `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(tmuxPane)} 1 Enter`; + } else if (process.platform === "darwin") { + const apps = MACOS_PROCESS_TERMINALS.map((a) => `"${a}"`).join(", "); + // Warp ships as Warp.app/MacOS/stable so its process name is "stable", not "warp". + // Check by bundle name first, then fall back to process-name search for other terminals. + script = [ + `osascript <<'APPLESCRIPT'`, + `delay ${delaySec}`, + `if application "Warp" is running then`, + ` tell application "Warp" to activate`, + ` delay 0.05`, + ` tell application "System Events"`, + ` keystroke "1"`, + ` key code 36`, + ` end tell`, + `else`, + ` tell application "System Events"`, + ` repeat with appName in {${apps}}`, + ` if exists (application process (appName as string)) then`, + ` set frontmost of application process (appName as string) to true`, + ` delay 0.05`, + ` keystroke "1"`, + ` key code 36`, + ` exit repeat`, + ` end if`, + ` end repeat`, + ` end tell`, + `end if`, + `APPLESCRIPT`, + ].join("\n"); + } + + if (!script) return; // Linux/Windows without tmux: user must press 1 manually + + const child = Bun.spawn(["bash", "-c", script], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + }); + child.unref(); +} From 2e1b50e72cb2ae7ad1a3dece1b1bf142344011d1 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 16:02:16 +0700 Subject: [PATCH 10/39] omx(team): auto-checkpoint worker-4 [unknown] --- apps/pi-extension/server/serverPlan.ts | 10 ++++++++-- packages/server/index.ts | 8 ++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 5b95e74ed..5db3c6507 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -1,5 +1,7 @@ import { randomUUID } from "node:crypto"; import { createServer } from "node:http"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { contentHash, deleteDraft } from "../generated/draft.js"; import { @@ -37,7 +39,7 @@ import { import { listenOnPort } from "./network.js"; import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; -import { readImprovementHook, getImprovementHookExpectedPath } from "../generated/improvement-hooks.js"; +import { readImprovementHook } from "../generated/improvement-hooks.js"; import { composeImproveContext } from "../generated/pfm-reminder.js"; import { detectProjectName, getRepoInfo } from "./project.js"; import { @@ -50,6 +52,10 @@ import { } from "./reference.js"; import { warmFileListCache } from "../generated/resolve-file.js"; +function getEnterPlanModeImproveHookExpectedPath(): string { + return join(homedir(), ".plannotator", "hooks", "compound", "enterplanmode-improve-hook.txt"); +} + export interface PlanReviewDecision { approved: boolean; feedback?: string; @@ -239,7 +245,7 @@ export async function startPlanReviewServer(options: { pfmReminder: { enabled: pfmEnabled }, improvementHook: { present: !!hook, - filePath: hook?.filePath ?? getImprovementHookExpectedPath("enterplanmode-improve"), + filePath: hook?.filePath ?? getEnterPlanModeImproveHookExpectedPath(), fileSize: hook?.content?.length ?? null, content: hook?.content ?? null, }, diff --git a/packages/server/index.ts b/packages/server/index.ts index 7a354bd0f..b6a6501e7 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -45,7 +45,7 @@ import { import { getRepoInfo } from "./repo"; import { detectProjectName } from "./project"; import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "./config"; -import { readImprovementHook, getImprovementHookExpectedPath } from "@plannotator/shared/improvement-hooks"; +import { readImprovementHook } from "@plannotator/shared/improvement-hooks"; import { composeImproveContext } from "@plannotator/shared/pfm-reminder"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, type OpencodeClient } from "./shared-handlers"; import { contentHash, deleteDraft } from "./draft"; @@ -63,6 +63,10 @@ export * from "./storage"; export { handleServerReady } from "./shared-handlers"; export { type VaultNode, buildFileTree } from "@plannotator/shared/reference-common"; +function getEnterPlanModeImproveHookExpectedPath(): string { + return join(homedir(), ".plannotator", "hooks", "compound", "enterplanmode-improve-hook.txt"); +} + // --- Types --- export interface ServerOptions { @@ -379,7 +383,7 @@ export async function startPlannotatorServer( pfmReminder: { enabled: pfmEnabled }, improvementHook: { present: !!hook, - filePath: hook?.filePath ?? getImprovementHookExpectedPath("enterplanmode-improve"), + filePath: hook?.filePath ?? getEnterPlanModeImproveHookExpectedPath(), fileSize: hook?.content?.length ?? null, content: hook?.content ?? null, }, From 95b47f6b5390c03b2cde91f23c18d7f0b2c393f9 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 5 May 2026 12:18:18 -0700 Subject: [PATCH 11/39] Install Plannotator command skills under Codex home (#669) * Install Plannotator skills under Codex home * Keep shared Plannotator skills in agent scope * Harden scoped skill migration --- scripts/install.cmd | 1 - scripts/install.ps1 | 1 - scripts/install.sh | 1 - scripts/install.test.ts | 8 ++++---- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/scripts/install.cmd b/scripts/install.cmd index a150f7447..0594b5cdf 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -531,7 +531,6 @@ if !ERRORLEVEL! equ 0 ( xcopy /s /y /q "apps\skills\*" "!CLAUDE_SKILLS_DIR!\" >nul 2>&1 if exist "apps\skills\plannotator-compound" xcopy /s /i /y /q "apps\skills\plannotator-compound" "!AGENTS_SKILLS_DIR!\plannotator-compound\" >nul 2>&1 if exist "apps\skills\plannotator-setup-goal" xcopy /s /i /y /q "apps\skills\plannotator-setup-goal" "!AGENTS_SKILLS_DIR!\plannotator-setup-goal\" >nul 2>&1 - if exist "apps\skills\plannotator-visual-explainer" xcopy /s /i /y /q "apps\skills\plannotator-visual-explainer" "!AGENTS_SKILLS_DIR!\plannotator-visual-explainer\" >nul 2>&1 if "!CODEX_AVAILABLE!"=="1" ( if not exist "!CODEX_SKILLS_DIR!" mkdir "!CODEX_SKILLS_DIR!" if exist "apps\skills\plannotator-review" xcopy /s /i /y /q "apps\skills\plannotator-review" "!CODEX_SKILLS_DIR!\plannotator-review\" >nul 2>&1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 843b13780..3cfeca2c6 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -576,7 +576,6 @@ if (Get-Command git -ErrorAction SilentlyContinue) { Copy-Item -Recurse -Force "apps\skills\*" $claudeSkillsDir Copy-SkillIfPresent "apps\skills\plannotator-compound" $agentsSkillsDir Copy-SkillIfPresent "apps\skills\plannotator-setup-goal" $agentsSkillsDir - Copy-SkillIfPresent "apps\skills\plannotator-visual-explainer" $agentsSkillsDir if ($codexAvailable) { New-Item -ItemType Directory -Force -Path $codexSkillsDir | Out-Null Copy-SkillIfPresent "apps\skills\plannotator-review" $codexSkillsDir diff --git a/scripts/install.sh b/scripts/install.sh index bb752556a..e27d0bf16 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -799,7 +799,6 @@ if command -v git &>/dev/null; then cp -r apps/skills/* "$CLAUDE_SKILLS_DIR/" copy_skill_if_present apps/skills/plannotator-compound "$AGENTS_SKILLS_DIR" copy_skill_if_present apps/skills/plannotator-setup-goal "$AGENTS_SKILLS_DIR" - copy_skill_if_present apps/skills/plannotator-visual-explainer "$AGENTS_SKILLS_DIR" if [ "$codex_available" -eq 1 ]; then mkdir -p "$CODEX_SKILLS_DIR" copy_skill_if_present apps/skills/plannotator-review "$CODEX_SKILLS_DIR" diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 86f17a14a..d295ff320 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -218,8 +218,8 @@ describe("install.ps1", () => { expect(script).toContain('Copy-SkillIfPresent "apps\\skills\\plannotator-compound" $agentsSkillsDir'); expect(script).toContain('Copy-SkillIfPresent "apps\\skills\\plannotator-setup-goal" $agentsSkillsDir'); expect(script).toContain("if ($codexAvailable)"); - expect(script).not.toContain('Copy-Item -Recurse -Force "skills\\*" $codexSkillsDir'); - expect(script).not.toContain('Copy-Item -Recurse -Force "skills\\*" $agentsSkillsDir'); + expect(script).not.toContain('Copy-Item -Recurse -Force "apps\\skills\\*" $codexSkillsDir'); + expect(script).not.toContain('Copy-Item -Recurse -Force "apps\\skills\\*" $agentsSkillsDir'); expect(script).not.toContain('Copy-Item -Recurse -Force "apps\\skills\\plannotator-review" $codexSkillsDir'); expect(script).toContain('Skipping skills install (git not found)'); }); @@ -333,8 +333,8 @@ describe("install.cmd", () => { expect(script).toContain('if exist "apps\\skills\\plannotator-last" xcopy /s /i /y /q "apps\\skills\\plannotator-last" "!CODEX_SKILLS_DIR!\\plannotator-last\\"'); expect(script).toContain('if exist "apps\\skills\\plannotator-compound" xcopy /s /i /y /q "apps\\skills\\plannotator-compound" "!AGENTS_SKILLS_DIR!\\plannotator-compound\\"'); expect(script).toContain('if exist "apps\\skills\\plannotator-setup-goal" xcopy /s /i /y /q "apps\\skills\\plannotator-setup-goal" "!AGENTS_SKILLS_DIR!\\plannotator-setup-goal\\"'); - expect(script).not.toContain('xcopy /s /y /q "skills\\*" "!CODEX_SKILLS_DIR!\\"'); - expect(script).not.toContain('xcopy /s /y /q "skills\\*" "!AGENTS_SKILLS_DIR!\\"'); + expect(script).not.toContain('xcopy /s /y /q "apps\\skills\\*" "!CODEX_SKILLS_DIR!\\"'); + expect(script).not.toContain('xcopy /s /y /q "apps\\skills\\*" "!AGENTS_SKILLS_DIR!\\"'); expect(script).toContain("Skipping skills install"); }); From 7f0c8f05ddcde07aff4bcc87c5ddbb2e18055826 Mon Sep 17 00:00:00 2001 From: Nehr <127654909+AgileInnov8tor@users.noreply.github.com> Date: Wed, 6 May 2026 02:27:16 +0700 Subject: [PATCH 12/39] Expose bypass clear reminder permission mode (#668) * Preserve truthful approval semantics for Claude plan bypass Thread a clear-context reminder flag through approval decisions, expose a Claude Code-only approval entry that requests bypass mode, and keep the hook response honest by emitting a reminder instead of claiming context was cleared. Constraint: Claude Code PermissionRequest hooks have no documented clearContext response field and bypassPermissions is only a request when the mode is available. Rejected: adding a permission-mode enum or undocumented clearContext field | those would misrepresent hook capabilities and broaden the contract. Confidence: high Scope-risk: moderate Directive: Do not claim Plannotator clears context until Claude Code documents a hook field for that behavior; keep reminder copy truthful. Tested: bun test packages/server apps/hook; bun test packages/editor/wideMode.test.ts packages/ui/hooks/useAgentSettings.test.ts; bun test; bun run build:review; bun run build:hook; bun run typecheck; git diff --check --cached. Not-tested: Browser warning-dialog replay and interactive Claude Code smoke test. Co-authored-by: OmX * Keep approval payloads truthful across Claude Code and Pi Constraint: Claude Code hooks cannot clear context directly; the new action remains a truthful reminder plus bypass-mode approval. Rejected: Sending OpenCode agent-switch state for Claude Code | it leaked build (?) UI state and misleading payload fields. Confidence: high Scope-risk: narrow Directive: Keep OpenCode agent switching gated to opencode-origin approval payloads. Tested: bun run typecheck; bun test; bun run build:review; bun run build:hook Not-tested: Manual browser click-through of the Claude Code dropdown. * Expose the clear-context reminder permission default Constraint: Claude Code hooks can only emit a systemMessage nudge, not clear context directly. Rejected: Server protocol changes | existing permissionMode plus clearContextNudge wire fields already support the behavior. Confidence: high Scope-risk: narrow Directive: Keep bypassPermissionsClearReminder as a UI/storage-only synthetic mode that decomposes before /api/approve. Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd apps/review build && bun run build:hook; bun run --cwd packages/ui typecheck; git diff --check Not-tested: Root bun run typecheck could not run because tsc was not on PATH for the root script. * Reject invalid persisted permission modes Constraint: Stored browser values can be stale, corrupt, or from a future Plannotator build. Rejected: Trusting the storage read with a type assertion | invalid values could flow into UI state and approval request construction. Confidence: high Scope-risk: narrow Directive: Keep PermissionMode storage reads validated against PERMISSION_MODE_OPTIONS when adding or renaming modes. Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd packages/ui typecheck; git diff --check Not-tested: Full repo typecheck/build, outside this review-fix scope. * Ensure approvals use the live permission setting Settings persisted permission mode changes, but App kept the original mode in React state and used that stale value when building approval payloads. Push the Settings change back into App so the selected clear-reminder mode reaches the hook decision. Constraint: Permission mode storage is cookie-backed while approval payload construction reads App state.\nRejected: Read permission cookies during approval | would couple approval construction to browser storage and duplicate Settings state.\nConfidence: high\nScope-risk: narrow\nDirective: Keep permission-mode writes synchronized with approval state when adding or changing modes.\nTested: bun test packages/editor/approvalBody.test.ts packages/ui/components/ApproveDropdown.test.tsx; bun x tsc --noEmit -p packages/ui/tsconfig.json; bun run build:hook; direct Playwright hook smoke verified bypassPermissionsClearReminder UI and hook payload.\nNot-tested: Live interactive Claude CLI session; direct hook/server simulation covered the wire output. --------- Co-authored-by: OmX --- apps/pi-extension/server.test.ts | 44 +--- packages/editor/App.tsx | 341 +++++++++++++++++++-------- packages/editor/approvalBody.test.ts | 119 +--------- packages/editor/approvalBody.ts | 29 +-- packages/server/index.ts | 10 +- packages/ui/utils/permissionMode.ts | 4 +- 6 files changed, 260 insertions(+), 287 deletions(-) diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index 97d38117c..7af6306d1 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -4,14 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - getGitContext, - getVcsContext, - prepareLocalReviewDiff, - runGitDiff, - startPlanReviewServer, - startReviewServer, -} from "./server"; +import { getGitContext, runGitDiff, startPlanReviewServer, startReviewServer } from "./server"; const tempDirs: string[] = []; const originalCwd = process.cwd(); @@ -133,8 +126,6 @@ afterEach(() => { }); describe("pi review server", () => { - const testIfJj = hasJj() ? test : test.skip; - test("plan approve preserves clear context nudge decisions", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = makeTempDir("plannotator-pi-plan-"); @@ -174,39 +165,6 @@ describe("pi review server", () => { } }); - test("plan clear-context setting endpoints are explicit unsupported fallbacks", async () => { - const homeDir = makeTempDir("plannotator-pi-home-"); - const repoDir = makeTempDir("plannotator-pi-plan-"); - process.env.HOME = homeDir; - process.chdir(repoDir); - process.env.PLANNOTATOR_PORT = String(await reservePort()); - - const server = await startPlanReviewServer({ - plan: "# Plan\n\nShip it.", - htmlContent: "plan", - origin: "pi", - permissionMode: "acceptEdits", - }); - - try { - const statusResponse = await fetch(`${server.url}/api/settings-status`); - await expect(statusResponse.json()).resolves.toEqual({ - settingEnabled: false, - consentGiven: false, - }); - - const enableResponse = await fetch(`${server.url}/api/enable-clear-context`, { - method: "POST", - }); - await expect(enableResponse.json()).resolves.toEqual({ - ok: false, - reason: "not-supported-in-pi-extension", - }); - } finally { - server.stop(); - } - }); - test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = initRepo(); diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 4c321364f..53e94bc82 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -15,6 +15,9 @@ import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolst import { StickyHeaderLane } from '@plannotator/ui/components/StickyHeaderLane'; import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning'; import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup'; +import { Settings } from '@plannotator/ui/components/Settings'; +import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons'; +import { ApproveDropdown, type ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; import { useSharing } from '@plannotator/ui/hooks/useSharing'; import { getCallbackConfig, CallbackAction, executeCallback } from '@plannotator/ui/utils/callback'; import { useAgents } from '@plannotator/ui/hooks/useAgents'; @@ -76,7 +79,6 @@ import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; -import type { ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -104,6 +106,7 @@ const App: React.FC = () => { const [showImport, setShowImport] = useState(false); const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false); + const [pendingApprovalOverride, setPendingApprovalOverride] = useState(null); const [showExitWarning, setShowExitWarning] = useState(false); // When the warning dialog confirms, route to the handler matching the button that opened it. const [exitWarningAction, setExitWarningAction] = useState<'close' | 'approve'>('close'); @@ -951,6 +954,7 @@ const App: React.FC = () => { // API mode handlers const handleApprove = async (override: ApprovalOverride = {}) => { + setPendingApprovalOverride(null); setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); @@ -961,19 +965,6 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - const shouldUseNativeClear = - origin === 'claude-code' && - pendingToolName === 'ExitPlanMode' && - (override.deferToNativeForClear || (override.permissionMode ?? permissionMode) === 'bypassPermissionsClearReminder'); - if (shouldUseNativeClear) { - try { - const response = await fetch('/api/enable-clear-context', { method: 'POST' }); - if (response.ok) setShowClearContextBanner(false); - } catch { - setShowClearContextBanner(true); - } - } - const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -981,7 +972,6 @@ const App: React.FC = () => { override, effectiveAgent, planSaveSettings, - toolName: pendingToolName, }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); @@ -1062,29 +1052,15 @@ const App: React.FC = () => { handleApprove(override); }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); - const claudeCodeExtraEntries = useMemo(() => { - if (origin !== 'claude-code') return []; - if (pendingToolName === 'ExitPlanMode') { - return [{ - id: 'approve-bypass-native-clear', - label: 'Approve + Bypass + Clear Context (native)', - description: "Defers to Claude Code's native plan-accept dialog so it can clear context and set bypass permissions.", - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - deferToNativeForClear: true, - }), - }]; - } - return [{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly outside plan acceptance.', - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }), - }]; - }, [approveWithClaudeCodeWarning, origin, pendingToolName]); + const claudeCodeExtraEntries = useMemo(() => (origin === 'claude-code' ? [{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly.', + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }), + }] : []), [approveWithClaudeCodeWarning, origin]); // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { @@ -1666,58 +1642,234 @@ const App: React.FC = () => {
- 0 || codeAnnotations.length > 0} - callbackConfig={callbackConfig} - taterMode={taterMode} - mobileSettingsOpen={mobileSettingsOpen} - gitUser={gitUser} - onCallbackFeedback={handleCallbackFeedback} - onCallbackApprove={handleCallbackApprove} - onAnnotateExit={handleHeaderAnnotateExit} - onAnnotateFeedback={handleHeaderAnnotateFeedback} - onAnnotateApprove={handleHeaderAnnotateApprove} - onFeedback={handleHeaderFeedback} - onApprove={handleHeaderApprove} - onAnnotationPanelToggle={handleAnnotationPanelToggle} - onArchiveCopy={archive.copy} - onArchiveDone={archive.done} - onTaterModeChange={handleTaterModeChange} - onIdentityChange={handleIdentityChange} - onUIPreferencesChange={setUiPrefs} - onOpenSettings={handleOpenSettings} - onCloseSettings={handleCloseSettings} - onOpenExport={handleOpenExport} - onCopyAgentInstructions={handleHeaderCopyAgentInstructions} - onDownloadAnnotations={handleHeaderDownloadAnnotations} - onPrint={handlePrint} - onCopyShareLink={handleHeaderCopyShareLink} - onOpenImport={handleOpenImport} - onSaveToObsidian={handleSaveToObsidian} - onSaveToBear={handleSaveToBear} - onSaveToOctarine={handleSaveToOctarine} - appVersion={typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'} - agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode} - obsidianConfigured={isObsidianConfigured()} - bearConfigured={getBearSettings().enabled} - octarineConfigured={isOctarineConfigured()} - /> + {/* Minimal Header */} +
+ + +
+ {/* Bot callback buttons — only shown when ?cb=&ct= params are present */} + {callbackConfig && !isApiMode && isSharedSession && ( + <> +
+ + + + )} + + {isApiMode && !linkedDocHook.isActive && archive.archiveMode && ( + <> + + + + )} + + {isApiMode && (!linkedDocHook.isActive || annotateMode) && !archive.archiveMode && ( + <> + {annotateMode ? ( + // Annotate mode: Close always visible, Send Annotations when annotations exist, + // Approve only when gate (review) mode is enabled (#570). + <> + { + if (hasAnyAnnotations) { + setExitWarningAction('close'); + setShowExitWarning(true); + } else { + handleAnnotateExit(); + } + }} + disabled={isSubmitting || isExiting} + isLoading={isExiting} + /> + {hasAnyAnnotations && ( + + )} + + ) : ( + // Plan mode: Send Feedback + { + const docAnnotations = linkedDocHook.getDocAnnotations(); + const hasDocAnnotations = Array.from(docAnnotations.values()).some( + (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 + ); + if (allAnnotations.length === 0 && codeAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { + setShowFeedbackPrompt(true); + } else { + handleDeny(); + } + }} + disabled={isSubmitting} + isLoading={isSubmitting} + label="Send Feedback" + title="Send Feedback" + /> + )} + + {(!annotateMode || gate) && ( + !annotateMode && ( + (origin === 'opencode' && availableAgents.length > 0) || + (origin === 'claude-code' && claudeCodeExtraEntries.length > 0) + ) ? ( + { + if (origin === 'opencode') { + const warning = getAgentWarning(); + if (warning) { + setAgentWarningMessage(warning); + setShowAgentWarning(true); + return; + } + } + approveWithClaudeCodeWarning(); + }} + agents={origin === 'opencode' ? availableAgents : []} + extraEntries={claudeCodeExtraEntries} + disabled={isSubmitting} + isLoading={isSubmitting} + /> + ) : ( +
+ { + if (annotateMode) { + if (hasAnyAnnotations) { + setExitWarningAction('approve'); + setShowExitWarning(true); + return; + } + handleAnnotateApprove(); + return; + } + if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { + setPendingApprovalOverride({}); + setShowClaudeCodeWarning(true); + return; + } + if (origin === 'opencode') { + const warning = getAgentWarning(); + if (warning) { + setAgentWarningMessage(warning); + setShowAgentWarning(true); + return; + } + } + handleApprove(); + }} + disabled={isSubmitting || (annotateMode && isExiting)} + isLoading={isSubmitting} + dimmed={!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && (allAnnotations.length > 0 || codeAnnotations.length > 0)} + title={annotateMode ? 'Approve — no changes requested' : undefined} + /> + {!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && (allAnnotations.length > 0 || codeAnnotations.length > 0) && ( +
+
+
+ {agentName} doesn't support feedback on approval. Your annotations won't be seen. +
+ )} +
+ ) + )} + +
+ + )} + + {/* Annotations panel toggle — top-level header button */} + + + {/* Settings dialog (controlled, button hidden — opened from PlanHeaderMenu) */} +
+ setMobileSettingsOpen(false)} + gitUser={gitUser} + /> +
+ + { + setMobileSettingsOpen(true); + }} + onOpenExport={() => { setInitialExportTab(undefined); setShowExport(true); }} + onCopyAgentInstructions={handleCopyAgentInstructions} + onDownloadAnnotations={handleDownloadAnnotations} + onPrint={() => window.print()} + onCopyShareLink={handleCopyShareLink} + onOpenImport={() => setShowImport(true)} + onSaveToObsidian={() => handleQuickSaveToNotes('obsidian')} + onSaveToBear={() => handleQuickSaveToNotes('bear')} + onSaveToOctarine={() => handleQuickSaveToNotes('octarine')} + sharingEnabled={canShareCurrentSession} + isApiMode={isApiMode} + agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode} + obsidianConfigured={isObsidianConfigured()} + bearConfigured={getBearSettings().enabled} + octarineConfigured={isOctarineConfigured()} + /> +
+
+ {/* Linked document error banner */} {linkedDocHook.error && (
@@ -2059,11 +2211,14 @@ const App: React.FC = () => { {/* Claude Code annotation warning dialog */} setShowClaudeCodeWarning(false)} + onClose={() => { + setShowClaudeCodeWarning(false); + setPendingApprovalOverride(null); + }} onConfirm={() => { + const override = pendingApprovalOverride ?? {}; setShowClaudeCodeWarning(false); - const override = pendingApprovalOverride; - setPendingApprovalOverride({}); + setPendingApprovalOverride(null); handleApprove(override); }} title="Annotations Won't Be Sent" diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts index 4c6b974c3..dcb9a2451 100644 --- a/packages/editor/approvalBody.test.ts +++ b/packages/editor/approvalBody.test.ts @@ -1,47 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove } from './approvalBody'; - -describe('shouldEnableNativeClearBeforeApprove', () => { - test('enables native clear only for explicit Claude Code ExitPlanMode overrides', () => { - expect(shouldEnableNativeClearBeforeApprove({ - origin: 'claude-code', - toolName: 'ExitPlanMode', - override: { deferToNativeForClear: true }, - })).toBe(true); - - expect(shouldEnableNativeClearBeforeApprove({ - origin: 'claude-code', - toolName: 'ExitPlanMode', - override: { permissionMode: 'bypassPermissionsClearReminder' }, - })).toBe(false); - - expect(shouldEnableNativeClearBeforeApprove({ - origin: 'claude-code', - toolName: 'OtherTool', - override: { deferToNativeForClear: true }, - })).toBe(false); - }); -}); +import { buildApprovalRequestBody } from './approvalBody'; describe('buildApprovalRequestBody', () => { - test('maps bypass clear reminder mode to reminder fallback on ExitPlanMode', () => { + test('maps bypass clear reminder mode to Claude Code wire fields', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'bypassPermissionsClearReminder', - toolName: 'ExitPlanMode', - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('maps bypass clear reminder mode to reminder fallback outside ExitPlanMode', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'bypassPermissionsClearReminder', - toolName: 'OtherTool', planSaveSettings: { enabled: true }, })).toEqual({ permissionMode: 'bypassPermissions', @@ -67,7 +31,7 @@ describe('buildApprovalRequestBody', () => { }); }); - test('keeps bypass clear reminder override fallback fields for Claude Code approvals without ExitPlanMode', () => { + test('keeps bypass clear reminder override wire fields for Claude Code approvals', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'acceptEdits', @@ -82,22 +46,6 @@ describe('buildApprovalRequestBody', () => { }); }); - test('keeps bypass clear reminder override as reminder fallback when ExitPlanMode is known', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - toolName: 'ExitPlanMode', - override: { - permissionMode: 'bypassPermissionsClearReminder', - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - test('keeps agentSwitch for OpenCode approvals', () => { expect(buildApprovalRequestBody({ origin: 'opencode', @@ -121,65 +69,4 @@ describe('buildApprovalRequestBody', () => { planSave: { enabled: true }, }); }); - - test('forwards deferToNativeForClear only for explicit Claude Code ExitPlanMode bypass approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - toolName: 'ExitPlanMode', - override: { - permissionMode: 'bypassPermissions', - deferToNativeForClear: true, - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - deferToNativeForClear: true, - planSave: { enabled: true }, - }); - }); - - test('does not forward deferToNativeForClear without ExitPlanMode', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - toolName: 'OtherTool', - override: { - permissionMode: 'bypassPermissions', - deferToNativeForClear: true, - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - planSave: { enabled: true }, - }); - }); - - test('does not forward deferToNativeForClear for OpenCode approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'opencode', - permissionMode: 'acceptEdits', - effectiveAgent: 'build', - override: { - deferToNativeForClear: true, - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - agentSwitch: 'build', - planSave: { enabled: true }, - }); - }); - - test('does not forward deferToNativeForClear for Gemini origin', () => { - expect(buildApprovalRequestBody({ - origin: 'gemini-cli', - permissionMode: 'acceptEdits', - override: { - deferToNativeForClear: true, - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - planSave: { enabled: true }, - }); - }); }); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index 55a905fcc..5d8e92703 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -4,7 +4,6 @@ import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; export type ApprovalOverride = { permissionMode?: PermissionMode; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }; export interface ApprovalRequestBody { @@ -16,19 +15,6 @@ export interface ApprovalRequestBody { planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; -} - -export function shouldEnableNativeClearBeforeApprove(options: { - origin: Origin | null; - toolName?: string; - override?: ApprovalOverride; -}): boolean { - return ( - options.origin === 'claude-code' && - options.toolName === 'ExitPlanMode' && - options.override?.deferToNativeForClear === true - ); } export function buildApprovalRequestBody(options: { @@ -37,21 +23,16 @@ export function buildApprovalRequestBody(options: { override?: ApprovalOverride; effectiveAgent?: string; planSaveSettings: { enabled: boolean; customPath?: string | null }; - toolName?: string; }): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings, toolName } = options; + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; const body: ApprovalRequestBody = {}; if (origin === 'claude-code') { const effectivePermissionMode = override.permissionMode ?? permissionMode; - const wantsClearContext = effectivePermissionMode === 'bypassPermissionsClearReminder'; - const useNativeClear = shouldEnableNativeClearBeforeApprove({ origin, toolName, override }); - - body.permissionMode = wantsClearContext ? 'bypassPermissions' : effectivePermissionMode; - - if (useNativeClear) { - body.deferToNativeForClear = true; - } else if (override.clearContextNudge || wantsClearContext) { + body.permissionMode = effectivePermissionMode === 'bypassPermissionsClearReminder' + ? 'bypassPermissions' + : effectivePermissionMode; + if (override.clearContextNudge || effectivePermissionMode === 'bypassPermissionsClearReminder') { body.clearContextNudge = true; } } diff --git a/packages/server/index.ts b/packages/server/index.ts index b6a6501e7..0654d90d5 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -111,7 +111,6 @@ export interface ServerResult { agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -242,7 +241,6 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -251,7 +249,6 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }>; if (mode !== "archive") { @@ -529,7 +526,6 @@ export async function startPlannotatorServer( let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; let clearContextNudge: boolean | undefined; - let deferToNativeForClear: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -542,7 +538,6 @@ export async function startPlannotatorServer( planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -564,9 +559,6 @@ export async function startPlannotatorServer( if (body.clearContextNudge === true) { clearContextNudge = true; } - if (body.deferToNativeForClear === true) { - deferToNativeForClear = true; - } // Capture plan save settings if (body.planSave !== undefined) { @@ -613,7 +605,7 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge, deferToNativeForClear }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge }); return Response.json({ ok: true, savedPath }); } diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index fa016be2a..4143c1d75 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,7 +6,7 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls - * - bypassPermissionsClearReminder: Persisted UI mode that bypasses permissions and emits a /clear reminder after plan approval + * - bypassPermissionsClearReminder: Persisted UI mode that sends bypassPermissions plus a /clear reminder nudge * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -37,7 +37,7 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de { value: 'bypassPermissionsClearReminder', label: 'Bypass + /clear Reminder', - description: 'Bypass permissions after plan approval and emit a /clear reminder without invoking the native fresh-thread flow.', + description: 'Auto-approve all tool calls and emit a system message reminding you to run /clear (hooks cannot clear context directly).', }, { value: 'default', From e2441cc60b06dde377e96e0a1fbb5c41a75a59e9 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 16:04:19 +0700 Subject: [PATCH 13/39] task: resolve hook server conflict surfaces --- apps/pi-extension/plannotator-browser.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index 99fdc52b7..a47e89a8f 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -472,15 +472,13 @@ export async function startMarkdownAnnotationSession( sourceInfo?: string, sourceConverted?: boolean, gate?: boolean, - rawHtml?: string, - renderHtml?: boolean, ): Promise> { if (!ctx.hasUI || !planHtmlContent) { throw new Error("Plannotator annotation browser is unavailable in this session."); } let resolvedMarkdown = markdown; - if (!renderHtml && !resolvedMarkdown.trim() && existsSync(filePath)) { + if (!resolvedMarkdown.trim() && existsSync(filePath)) { try { const fileStat = statSync(filePath); if (!fileStat.isDirectory()) { @@ -500,8 +498,6 @@ export async function startMarkdownAnnotationSession( sourceInfo, sourceConverted, gate, - rawHtml, - renderHtml, htmlContent: planHtmlContent, sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, From b341b3ab64b319e6ca4efa8e205c2365063a95b8 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 5 May 2026 12:37:12 +0700 Subject: [PATCH 14/39] Preserve truthful approval semantics for Claude plan bypass Thread a clear-context reminder flag through approval decisions, expose a Claude Code-only approval entry that requests bypass mode, and keep the hook response honest by emitting a reminder instead of claiming context was cleared. Constraint: Claude Code PermissionRequest hooks have no documented clearContext response field and bypassPermissions is only a request when the mode is available. Rejected: adding a permission-mode enum or undocumented clearContext field | those would misrepresent hook capabilities and broaden the contract. Confidence: high Scope-risk: moderate Directive: Do not claim Plannotator clears context until Claude Code documents a hook field for that behavior; keep reminder copy truthful. Tested: bun test packages/server apps/hook; bun test packages/editor/wideMode.test.ts packages/ui/hooks/useAgentSettings.test.ts; bun test; bun run build:review; bun run build:hook; bun run typecheck; git diff --check --cached. Not-tested: Browser warning-dialog replay and interactive Claude Code smoke test. Co-authored-by: OmX --- packages/editor/App.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 53e94bc82..6f1cfa642 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -94,6 +94,11 @@ type NoteAutoSaveResults = { octarine?: boolean; }; +type ApprovalOverride = { + permissionMode?: PermissionMode; + clearContextNudge?: boolean; +}; + const App: React.FC = () => { const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); const [annotations, setAnnotations] = useState([]); From ef9640a2c62154f1090c0eb87e274da64d789e0c Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 5 May 2026 15:05:47 +0700 Subject: [PATCH 15/39] Keep approval payloads truthful across Claude Code and Pi Constraint: Claude Code hooks cannot clear context directly; the new action remains a truthful reminder plus bypass-mode approval. Rejected: Sending OpenCode agent-switch state for Claude Code | it leaked build (?) UI state and misleading payload fields. Confidence: high Scope-risk: narrow Directive: Keep OpenCode agent switching gated to opencode-origin approval payloads. Tested: bun run typecheck; bun test; bun run build:review; bun run build:hook Not-tested: Manual browser click-through of the Claude Code dropdown. --- packages/editor/App.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 6f1cfa642..53e94bc82 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -94,11 +94,6 @@ type NoteAutoSaveResults = { octarine?: boolean; }; -type ApprovalOverride = { - permissionMode?: PermissionMode; - clearContextNudge?: boolean; -}; - const App: React.FC = () => { const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); const [annotations, setAnnotations] = useState([]); From 77ac25126bc230b7241fe36f3fcbf59f0af56289 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 7 May 2026 02:20:25 +0700 Subject: [PATCH 16/39] Keep Plannotator approvals actionable for bypass clear context Route Claude Code plan approvals through explicit bypass payloads, consent-gated native clear-context deferral, and a fallback /clear nudge so selecting the clear-context mode no longer collapses into a silent approval no-op. The active ignored hook bundle was rebuilt locally after this source change. Constraint: Claude Code hooks cannot directly clear context; native clear requires showClearContextOnPlanAccept and user consent.\nRejected: Treating bypassPermissionsClearReminder as a raw permissionMode | Claude Code only accepts bypassPermissions on the wire and would ignore the local UI-only value.\nConfidence: high\nScope-risk: moderate\nDirective: Rebuild apps/hook/dist/index.html after changing plan-review UI because the local plannotator launcher imports the ignored dist bundle at runtime.\nTested: git diff --check; bun run typecheck; bun test; bun run build:review; bun run build:hook; fixed-port hook smoke for /api/settings-status and native-clear /api/approve.\nNot-tested: Manual click-through in Claude Code native plan-accept dialog. --- apps/pi-extension/server.test.ts | 33 +++++++++++++++++++++++ packages/editor/App.tsx | 46 +++++++++++++++++++++++++------- packages/editor/approvalBody.ts | 5 +++- packages/server/index.ts | 10 ++++++- 4 files changed, 83 insertions(+), 11 deletions(-) diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index 7af6306d1..0f8dd33f2 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -165,6 +165,39 @@ describe("pi review server", () => { } }); + test("plan clear-context setting endpoints are explicit unsupported fallbacks", async () => { + const homeDir = makeTempDir("plannotator-pi-home-"); + const repoDir = makeTempDir("plannotator-pi-plan-"); + process.env.HOME = homeDir; + process.chdir(repoDir); + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + const server = await startPlanReviewServer({ + plan: "# Plan\n\nShip it.", + htmlContent: "plan", + origin: "pi", + permissionMode: "acceptEdits", + }); + + try { + const statusResponse = await fetch(`${server.url}/api/settings-status`); + await expect(statusResponse.json()).resolves.toEqual({ + settingEnabled: false, + consentGiven: false, + }); + + const enableResponse = await fetch(`${server.url}/api/enable-clear-context`, { + method: "POST", + }); + await expect(enableResponse.json()).resolves.toEqual({ + ok: false, + reason: "not-supported-in-pi-extension", + }); + } finally { + server.stop(); + } + }); + test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = initRepo(); diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 53e94bc82..8710cc6ae 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -965,6 +965,19 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; + const shouldUseNativeClear = + origin === 'claude-code' && + pendingToolName === 'ExitPlanMode' && + (override.deferToNativeForClear || (override.permissionMode ?? permissionMode) === 'bypassPermissionsClearReminder'); + if (shouldUseNativeClear) { + try { + const response = await fetch('/api/enable-clear-context', { method: 'POST' }); + if (response.ok) setShowClearContextBanner(false); + } catch { + setShowClearContextBanner(true); + } + } + const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -972,6 +985,7 @@ const App: React.FC = () => { override, effectiveAgent, planSaveSettings, + toolName: pendingToolName, }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); @@ -1052,15 +1066,29 @@ const App: React.FC = () => { handleApprove(override); }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); - const claudeCodeExtraEntries = useMemo(() => (origin === 'claude-code' ? [{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly.', - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }), - }] : []), [approveWithClaudeCodeWarning, origin]); + const claudeCodeExtraEntries = useMemo(() => { + if (origin !== 'claude-code') return []; + if (pendingToolName === 'ExitPlanMode') { + return [{ + id: 'approve-bypass-native-clear', + label: 'Approve + Bypass + Clear Context (native)', + description: "Defers to Claude Code's native plan-accept dialog so it can clear context and set bypass permissions.", + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }), + }]; + } + return [{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly outside plan acceptance.', + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }), + }]; + }, [approveWithClaudeCodeWarning, origin, pendingToolName]); // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index 5d8e92703..31180df9d 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -4,6 +4,7 @@ import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; export type ApprovalOverride = { permissionMode?: PermissionMode; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }; export interface ApprovalRequestBody { @@ -15,6 +16,7 @@ export interface ApprovalRequestBody { planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; } export function buildApprovalRequestBody(options: { @@ -23,8 +25,9 @@ export function buildApprovalRequestBody(options: { override?: ApprovalOverride; effectiveAgent?: string; planSaveSettings: { enabled: boolean; customPath?: string | null }; + toolName?: string; }): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings, toolName } = options; const body: ApprovalRequestBody = {}; if (origin === 'claude-code') { diff --git a/packages/server/index.ts b/packages/server/index.ts index 0654d90d5..b6a6501e7 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -111,6 +111,7 @@ export interface ServerResult { agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -241,6 +242,7 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -249,6 +251,7 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; if (mode !== "archive") { @@ -526,6 +529,7 @@ export async function startPlannotatorServer( let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; let clearContextNudge: boolean | undefined; + let deferToNativeForClear: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -538,6 +542,7 @@ export async function startPlannotatorServer( planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -559,6 +564,9 @@ export async function startPlannotatorServer( if (body.clearContextNudge === true) { clearContextNudge = true; } + if (body.deferToNativeForClear === true) { + deferToNativeForClear = true; + } // Capture plan save settings if (body.planSave !== undefined) { @@ -605,7 +613,7 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge, deferToNativeForClear }); return Response.json({ ok: true, savedPath }); } From fb38d31fad880a10d5e44bcf53b58bbdc46a120d Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Mon, 11 May 2026 18:23:49 +0700 Subject: [PATCH 17/39] Keep plan approvals in the current session by default Constraint: Approved Ralph plan required saved bypassPermissionsClearReminder to nudge /clear without native fresh-thread deferral.\nRejected: Reusing native clear as the default | it can restart or open a fresh thread unexpectedly.\nConfidence: high\nScope-risk: moderate\nDirective: Treat deferToNativeForClear as an explicit native/fresh-thread escape hatch only; do not wire it to saved reminder mode.\nTested: bun test packages/editor/approvalBody.test.ts apps/hook/server/keystrokeInjector.test.ts; git diff --check scoped files; bun run typecheck; bun test; bun run build:hook; architect verification approved.\nNot-tested: Interactive manual browser smoke of Claude Code native dialog selection. --- packages/editor/App.tsx | 12 ++++-------- packages/editor/approvalBody.test.ts | 24 +++++++++++++++++++++++- packages/editor/approvalBody.ts | 12 ++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 8710cc6ae..85a06ce60 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,7 +78,7 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; -import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; +import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -965,11 +965,7 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - const shouldUseNativeClear = - origin === 'claude-code' && - pendingToolName === 'ExitPlanMode' && - (override.deferToNativeForClear || (override.permissionMode ?? permissionMode) === 'bypassPermissionsClearReminder'); - if (shouldUseNativeClear) { + if (shouldEnableNativeClearBeforeApprove({ origin, toolName: pendingToolName, override })) { try { const response = await fetch('/api/enable-clear-context', { method: 'POST' }); if (response.ok) setShowClearContextBanner(false); @@ -1071,8 +1067,8 @@ const App: React.FC = () => { if (pendingToolName === 'ExitPlanMode') { return [{ id: 'approve-bypass-native-clear', - label: 'Approve + Bypass + Clear Context (native)', - description: "Defers to Claude Code's native plan-accept dialog so it can clear context and set bypass permissions.", + label: 'Approve + Bypass + Fresh Thread / Native Clear', + description: "Defers to the native plan-accept dialog. This may restart or open a fresh thread while setting bypass permissions.", onSelect: () => approveWithClaudeCodeWarning({ permissionMode: 'bypassPermissions', deferToNativeForClear: true, diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts index dcb9a2451..31615e6c1 100644 --- a/packages/editor/approvalBody.test.ts +++ b/packages/editor/approvalBody.test.ts @@ -1,5 +1,27 @@ import { describe, expect, test } from 'bun:test'; -import { buildApprovalRequestBody } from './approvalBody'; +import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove } from './approvalBody'; + +describe('shouldEnableNativeClearBeforeApprove', () => { + test('enables native clear only for explicit Claude Code ExitPlanMode overrides', () => { + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + toolName: 'ExitPlanMode', + override: { deferToNativeForClear: true }, + })).toBe(true); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + toolName: 'ExitPlanMode', + override: { permissionMode: 'bypassPermissionsClearReminder' }, + })).toBe(false); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + toolName: 'OtherTool', + override: { deferToNativeForClear: true }, + })).toBe(false); + }); +}); describe('buildApprovalRequestBody', () => { test('maps bypass clear reminder mode to Claude Code wire fields', () => { diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index 31180df9d..378e74ce5 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -19,6 +19,18 @@ export interface ApprovalRequestBody { deferToNativeForClear?: boolean; } +export function shouldEnableNativeClearBeforeApprove(options: { + origin: Origin | null; + toolName?: string; + override?: ApprovalOverride; +}): boolean { + return ( + options.origin === 'claude-code' && + options.toolName === 'ExitPlanMode' && + options.override?.deferToNativeForClear === true + ); +} + export function buildApprovalRequestBody(options: { origin: Origin | null; permissionMode: PermissionMode; From d6e92a5216d8c705fa38f11b484da8803876fd77 Mon Sep 17 00:00:00 2001 From: Nehr <127654909+AgileInnov8tor@users.noreply.github.com> Date: Wed, 6 May 2026 02:27:16 +0700 Subject: [PATCH 18/39] Expose bypass clear reminder permission mode (#668) * Preserve truthful approval semantics for Claude plan bypass Thread a clear-context reminder flag through approval decisions, expose a Claude Code-only approval entry that requests bypass mode, and keep the hook response honest by emitting a reminder instead of claiming context was cleared. Constraint: Claude Code PermissionRequest hooks have no documented clearContext response field and bypassPermissions is only a request when the mode is available. Rejected: adding a permission-mode enum or undocumented clearContext field | those would misrepresent hook capabilities and broaden the contract. Confidence: high Scope-risk: moderate Directive: Do not claim Plannotator clears context until Claude Code documents a hook field for that behavior; keep reminder copy truthful. Tested: bun test packages/server apps/hook; bun test packages/editor/wideMode.test.ts packages/ui/hooks/useAgentSettings.test.ts; bun test; bun run build:review; bun run build:hook; bun run typecheck; git diff --check --cached. Not-tested: Browser warning-dialog replay and interactive Claude Code smoke test. Co-authored-by: OmX * Keep approval payloads truthful across Claude Code and Pi Constraint: Claude Code hooks cannot clear context directly; the new action remains a truthful reminder plus bypass-mode approval. Rejected: Sending OpenCode agent-switch state for Claude Code | it leaked build (?) UI state and misleading payload fields. Confidence: high Scope-risk: narrow Directive: Keep OpenCode agent switching gated to opencode-origin approval payloads. Tested: bun run typecheck; bun test; bun run build:review; bun run build:hook Not-tested: Manual browser click-through of the Claude Code dropdown. * Expose the clear-context reminder permission default Constraint: Claude Code hooks can only emit a systemMessage nudge, not clear context directly. Rejected: Server protocol changes | existing permissionMode plus clearContextNudge wire fields already support the behavior. Confidence: high Scope-risk: narrow Directive: Keep bypassPermissionsClearReminder as a UI/storage-only synthetic mode that decomposes before /api/approve. Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd apps/review build && bun run build:hook; bun run --cwd packages/ui typecheck; git diff --check Not-tested: Root bun run typecheck could not run because tsc was not on PATH for the root script. * Reject invalid persisted permission modes Constraint: Stored browser values can be stale, corrupt, or from a future Plannotator build. Rejected: Trusting the storage read with a type assertion | invalid values could flow into UI state and approval request construction. Confidence: high Scope-risk: narrow Directive: Keep PermissionMode storage reads validated against PERMISSION_MODE_OPTIONS when adding or renaming modes. Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd packages/ui typecheck; git diff --check Not-tested: Full repo typecheck/build, outside this review-fix scope. * Ensure approvals use the live permission setting Settings persisted permission mode changes, but App kept the original mode in React state and used that stale value when building approval payloads. Push the Settings change back into App so the selected clear-reminder mode reaches the hook decision. Constraint: Permission mode storage is cookie-backed while approval payload construction reads App state.\nRejected: Read permission cookies during approval | would couple approval construction to browser storage and duplicate Settings state.\nConfidence: high\nScope-risk: narrow\nDirective: Keep permission-mode writes synchronized with approval state when adding or changing modes.\nTested: bun test packages/editor/approvalBody.test.ts packages/ui/components/ApproveDropdown.test.tsx; bun x tsc --noEmit -p packages/ui/tsconfig.json; bun run build:hook; direct Playwright hook smoke verified bypassPermissionsClearReminder UI and hook payload.\nNot-tested: Live interactive Claude CLI session; direct hook/server simulation covered the wire output. --------- Co-authored-by: OmX --- apps/pi-extension/server.test.ts | 33 --------------------- packages/editor/App.tsx | 44 +++++++--------------------- packages/editor/approvalBody.test.ts | 24 +-------------- packages/editor/approvalBody.ts | 17 +---------- packages/server/index.ts | 10 +------ 5 files changed, 13 insertions(+), 115 deletions(-) diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index 0f8dd33f2..7af6306d1 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -165,39 +165,6 @@ describe("pi review server", () => { } }); - test("plan clear-context setting endpoints are explicit unsupported fallbacks", async () => { - const homeDir = makeTempDir("plannotator-pi-home-"); - const repoDir = makeTempDir("plannotator-pi-plan-"); - process.env.HOME = homeDir; - process.chdir(repoDir); - process.env.PLANNOTATOR_PORT = String(await reservePort()); - - const server = await startPlanReviewServer({ - plan: "# Plan\n\nShip it.", - htmlContent: "plan", - origin: "pi", - permissionMode: "acceptEdits", - }); - - try { - const statusResponse = await fetch(`${server.url}/api/settings-status`); - await expect(statusResponse.json()).resolves.toEqual({ - settingEnabled: false, - consentGiven: false, - }); - - const enableResponse = await fetch(`${server.url}/api/enable-clear-context`, { - method: "POST", - }); - await expect(enableResponse.json()).resolves.toEqual({ - ok: false, - reason: "not-supported-in-pi-extension", - }); - } finally { - server.stop(); - } - }); - test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => { const homeDir = makeTempDir("plannotator-pi-home-"); const repoDir = initRepo(); diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 85a06ce60..53e94bc82 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,7 +78,7 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; -import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove, type ApprovalOverride } from './approvalBody'; +import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -965,15 +965,6 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - if (shouldEnableNativeClearBeforeApprove({ origin, toolName: pendingToolName, override })) { - try { - const response = await fetch('/api/enable-clear-context', { method: 'POST' }); - if (response.ok) setShowClearContextBanner(false); - } catch { - setShowClearContextBanner(true); - } - } - const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -981,7 +972,6 @@ const App: React.FC = () => { override, effectiveAgent, planSaveSettings, - toolName: pendingToolName, }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); @@ -1062,29 +1052,15 @@ const App: React.FC = () => { handleApprove(override); }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); - const claudeCodeExtraEntries = useMemo(() => { - if (origin !== 'claude-code') return []; - if (pendingToolName === 'ExitPlanMode') { - return [{ - id: 'approve-bypass-native-clear', - label: 'Approve + Bypass + Fresh Thread / Native Clear', - description: "Defers to the native plan-accept dialog. This may restart or open a fresh thread while setting bypass permissions.", - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - deferToNativeForClear: true, - }), - }]; - } - return [{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly outside plan acceptance.', - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }), - }]; - }, [approveWithClaudeCodeWarning, origin, pendingToolName]); + const claudeCodeExtraEntries = useMemo(() => (origin === 'claude-code' ? [{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly.', + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }), + }] : []), [approveWithClaudeCodeWarning, origin]); // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts index 31615e6c1..dcb9a2451 100644 --- a/packages/editor/approvalBody.test.ts +++ b/packages/editor/approvalBody.test.ts @@ -1,27 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove } from './approvalBody'; - -describe('shouldEnableNativeClearBeforeApprove', () => { - test('enables native clear only for explicit Claude Code ExitPlanMode overrides', () => { - expect(shouldEnableNativeClearBeforeApprove({ - origin: 'claude-code', - toolName: 'ExitPlanMode', - override: { deferToNativeForClear: true }, - })).toBe(true); - - expect(shouldEnableNativeClearBeforeApprove({ - origin: 'claude-code', - toolName: 'ExitPlanMode', - override: { permissionMode: 'bypassPermissionsClearReminder' }, - })).toBe(false); - - expect(shouldEnableNativeClearBeforeApprove({ - origin: 'claude-code', - toolName: 'OtherTool', - override: { deferToNativeForClear: true }, - })).toBe(false); - }); -}); +import { buildApprovalRequestBody } from './approvalBody'; describe('buildApprovalRequestBody', () => { test('maps bypass clear reminder mode to Claude Code wire fields', () => { diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index 378e74ce5..5d8e92703 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -4,7 +4,6 @@ import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; export type ApprovalOverride = { permissionMode?: PermissionMode; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }; export interface ApprovalRequestBody { @@ -16,19 +15,6 @@ export interface ApprovalRequestBody { planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; -} - -export function shouldEnableNativeClearBeforeApprove(options: { - origin: Origin | null; - toolName?: string; - override?: ApprovalOverride; -}): boolean { - return ( - options.origin === 'claude-code' && - options.toolName === 'ExitPlanMode' && - options.override?.deferToNativeForClear === true - ); } export function buildApprovalRequestBody(options: { @@ -37,9 +23,8 @@ export function buildApprovalRequestBody(options: { override?: ApprovalOverride; effectiveAgent?: string; planSaveSettings: { enabled: boolean; customPath?: string | null }; - toolName?: string; }): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings, toolName } = options; + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; const body: ApprovalRequestBody = {}; if (origin === 'claude-code') { diff --git a/packages/server/index.ts b/packages/server/index.ts index b6a6501e7..0654d90d5 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -111,7 +111,6 @@ export interface ServerResult { agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -242,7 +241,6 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -251,7 +249,6 @@ export async function startPlannotatorServer( agentSwitch?: string; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }>; if (mode !== "archive") { @@ -529,7 +526,6 @@ export async function startPlannotatorServer( let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; let clearContextNudge: boolean | undefined; - let deferToNativeForClear: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -542,7 +538,6 @@ export async function startPlannotatorServer( planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; - deferToNativeForClear?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -564,9 +559,6 @@ export async function startPlannotatorServer( if (body.clearContextNudge === true) { clearContextNudge = true; } - if (body.deferToNativeForClear === true) { - deferToNativeForClear = true; - } // Capture plan save settings if (body.planSave !== undefined) { @@ -613,7 +605,7 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge, deferToNativeForClear }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge }); return Response.json({ ok: true, savedPath }); } From 3b9fda45937def68997e56b55339852eaa390b4d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 6 May 2026 16:10:31 -0700 Subject: [PATCH 19/39] Revert "Expose bypass clear reminder permission mode (#668)" This reverts commit 3b88415aeb0276162bfd6a57958e1f4d599c0c98. --- apps/hook/server/index.ts | 4 - apps/pi-extension/plannotator-browser.ts | 1 - apps/pi-extension/plannotator-events.ts | 2 - apps/pi-extension/server/serverPlan.ts | 4 - packages/editor/App.tsx | 41 ++--- packages/editor/approvalBody.test.ts | 72 -------- packages/editor/approvalBody.ts | 50 ------ packages/server/index.ts | 12 +- .../ui/components/ApproveDropdown.test.tsx | 24 --- packages/ui/components/ApproveDropdown.tsx | 157 ++++++------------ packages/ui/components/Settings.tsx | 4 +- packages/ui/utils/permissionMode.ts | 16 +- 12 files changed, 65 insertions(+), 322 deletions(-) delete mode 100644 packages/editor/approvalBody.test.ts delete mode 100644 packages/editor/approvalBody.ts delete mode 100644 packages/ui/components/ApproveDropdown.test.tsx diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 3eb91bda0..f3cdc2c25 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1302,10 +1302,6 @@ if (args[0] === "sessions") { console.log( JSON.stringify({ - ...(result.clearContextNudge && { - systemMessage: - "Plannotator requested bypass mode. Hooks cannot clear context. Run /clear before continuing if you want a fresh implementation session.", - }), hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index a47e89a8f..2235bca6f 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -35,7 +35,6 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; } export interface BrowserDecisionSession { diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index f2754a6de..12845ae70 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -73,7 +73,6 @@ export interface PlannotatorReviewResultEvent { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; } export interface PlannotatorReviewStatusPayload { @@ -249,7 +248,6 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { savedPath: result.savedPath, agentSwitch: result.agentSwitch, permissionMode: result.permissionMode, - clearContextNudge: result.clearContextNudge, } satisfies PlannotatorReviewResultEvent; setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult }); pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 5db3c6507..e28e88dc3 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -62,7 +62,6 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; } export interface PlanServerResult { @@ -376,7 +375,6 @@ export async function startPlanReviewServer(options: { let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; - let clearContextNudge: boolean | undefined; let planSaveEnabled = true; let planSaveCustomPath: string | undefined; try { @@ -385,7 +383,6 @@ export async function startPlanReviewServer(options: { if (body.agentSwitch) agentSwitch = body.agentSwitch as string; if (body.permissionMode) requestedPermissionMode = body.permissionMode as string; - if (body.clearContextNudge === true) clearContextNudge = true; if (body.planSave !== undefined) { const ps = body.planSave as { enabled: boolean; customPath?: string }; planSaveEnabled = ps.enabled; @@ -447,7 +444,6 @@ export async function startPlanReviewServer(options: { savedPath, agentSwitch, permissionMode: effectivePermissionMode, - clearContextNudge, }); json(res, { ok: true, savedPath }); } else if ( diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 53e94bc82..25d64af52 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,7 +78,6 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; -import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -106,7 +105,6 @@ const App: React.FC = () => { const [showImport, setShowImport] = useState(false); const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false); - const [pendingApprovalOverride, setPendingApprovalOverride] = useState(null); const [showExitWarning, setShowExitWarning] = useState(false); // When the warning dialog confirms, route to the handler matching the button that opened it. const [exitWarningAction, setExitWarningAction] = useState<'close' | 'approve'>('close'); @@ -953,8 +951,7 @@ const App: React.FC = () => { }; // API mode handlers - const handleApprove = async (override: ApprovalOverride = {}) => { - setPendingApprovalOverride(null); + const handleApprove = async () => { setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); @@ -965,6 +962,14 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; + // Build request body - include integrations if enabled + const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {}; + + // Include permission mode for Claude Code + if (origin === 'claude-code') { + body.permissionMode = permissionMode; + } + const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -1043,25 +1048,6 @@ const App: React.FC = () => { } }; - const approveWithClaudeCodeWarning = useCallback((override: ApprovalOverride = {}) => { - setPendingApprovalOverride(override); - if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { - setShowClaudeCodeWarning(true); - return; - } - handleApprove(override); - }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); - - const claudeCodeExtraEntries = useMemo(() => (origin === 'claude-code' ? [{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly.', - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }), - }] : []), [approveWithClaudeCodeWarning, origin]); - // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); @@ -2211,15 +2197,10 @@ const App: React.FC = () => { {/* Claude Code annotation warning dialog */} { - setShowClaudeCodeWarning(false); - setPendingApprovalOverride(null); - }} + onClose={() => setShowClaudeCodeWarning(false)} onConfirm={() => { - const override = pendingApprovalOverride ?? {}; setShowClaudeCodeWarning(false); - setPendingApprovalOverride(null); - handleApprove(override); + handleApprove(); }} title="Annotations Won't Be Sent" message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.} diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts deleted file mode 100644 index dcb9a2451..000000000 --- a/packages/editor/approvalBody.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { buildApprovalRequestBody } from './approvalBody'; - -describe('buildApprovalRequestBody', () => { - test('maps bypass clear reminder mode to Claude Code wire fields', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'bypassPermissionsClearReminder', - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('omits agentSwitch for Claude Code approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - effectiveAgent: 'build', - override: { - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('keeps bypass clear reminder override wire fields for Claude Code approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - override: { - permissionMode: 'bypassPermissionsClearReminder', - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('keeps agentSwitch for OpenCode approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'opencode', - permissionMode: 'acceptEdits', - effectiveAgent: 'build', - planSaveSettings: { enabled: true }, - })).toEqual({ - agentSwitch: 'build', - planSave: { enabled: true }, - }); - }); - - test('ignores bypass clear reminder mode for OpenCode approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'opencode', - permissionMode: 'bypassPermissionsClearReminder', - effectiveAgent: 'build', - planSaveSettings: { enabled: true }, - })).toEqual({ - agentSwitch: 'build', - planSave: { enabled: true }, - }); - }); -}); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts deleted file mode 100644 index 5d8e92703..000000000 --- a/packages/editor/approvalBody.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Origin } from '@plannotator/shared/agents'; -import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; - -export type ApprovalOverride = { - permissionMode?: PermissionMode; - clearContextNudge?: boolean; -}; - -export interface ApprovalRequestBody { - obsidian?: object; - bear?: object; - octarine?: object; - feedback?: string; - agentSwitch?: string; - planSave?: { enabled: boolean; customPath?: string }; - permissionMode?: string; - clearContextNudge?: boolean; -} - -export function buildApprovalRequestBody(options: { - origin: Origin | null; - permissionMode: PermissionMode; - override?: ApprovalOverride; - effectiveAgent?: string; - planSaveSettings: { enabled: boolean; customPath?: string | null }; -}): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; - const body: ApprovalRequestBody = {}; - - if (origin === 'claude-code') { - const effectivePermissionMode = override.permissionMode ?? permissionMode; - body.permissionMode = effectivePermissionMode === 'bypassPermissionsClearReminder' - ? 'bypassPermissions' - : effectivePermissionMode; - if (override.clearContextNudge || effectivePermissionMode === 'bypassPermissionsClearReminder') { - body.clearContextNudge = true; - } - } - - if (origin === 'opencode' && effectiveAgent) { - body.agentSwitch = effectiveAgent; - } - - body.planSave = { - enabled: planSaveSettings.enabled, - ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), - }; - - return body; -} diff --git a/packages/server/index.ts b/packages/server/index.ts index 0654d90d5..f17a233c6 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -110,7 +110,6 @@ export interface ServerResult { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -240,7 +239,6 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -248,7 +246,6 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; }>; if (mode !== "archive") { @@ -525,7 +522,6 @@ export async function startPlannotatorServer( let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; - let clearContextNudge: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -537,7 +533,6 @@ export async function startPlannotatorServer( agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; - clearContextNudge?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -555,11 +550,6 @@ export async function startPlannotatorServer( requestedPermissionMode = body.permissionMode; } - // Capture optional /clear reminder request for Claude Code approval flow - if (body.clearContextNudge === true) { - clearContextNudge = true; - } - // Capture plan save settings if (body.planSave !== undefined) { planSaveEnabled = body.planSave.enabled; @@ -605,7 +595,7 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode }); return Response.json({ ok: true, savedPath }); } diff --git a/packages/ui/components/ApproveDropdown.test.tsx b/packages/ui/components/ApproveDropdown.test.tsx deleted file mode 100644 index 967f8e4be..000000000 --- a/packages/ui/components/ApproveDropdown.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { describe, expect, test } from 'bun:test'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { ApproveDropdown } from './ApproveDropdown'; - -describe('ApproveDropdown', () => { - test('does not show agent-switch label when only extra approval entries are enabled', () => { - const html = renderToStaticMarkup( - {}} - agents={[]} - extraEntries={[{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - onSelect: () => {}, - }]} - />, - ); - - expect(html).toContain('Approve'); - expect(html).not.toContain('build'); - expect(html).not.toContain('(?)'); - }); -}); diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index 9424696d3..ab9c48c7f 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -2,21 +2,11 @@ import React, { useState, useRef, useEffect } from 'react'; import type { Agent } from '../hooks/useAgents'; import { getAgentSwitchSettings, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch'; -export interface ApproveExtraEntry { - id: string; - label: string; - description?: string; - onSelect: () => void; - disabled?: boolean; -} - interface ApproveDropdownProps { onApprove: () => void; agents: Agent[]; disabled?: boolean; isLoading?: boolean; - extraEntries?: ApproveExtraEntry[]; - showAgentSwitch?: boolean; } function getSelectedLabel(setting: AgentSwitchSettings, agents: Agent[]): string | null { @@ -45,8 +35,6 @@ export const ApproveDropdown: React.FC = ({ agents, disabled = false, isLoading = false, - extraEntries = [], - showAgentSwitch, }) => { const [setting, setSetting] = useState(() => getAgentSwitchSettings()); const [isOpen, setIsOpen] = useState(false); @@ -69,20 +57,16 @@ export const ApproveDropdown: React.FC = ({ }; }, []); - const hasExtraEntries = extraEntries.length > 0; - const shouldShowAgentSwitch = showAgentSwitch ?? agents.length > 0; - const hasDropdownContent = hasExtraEntries || shouldShowAgentSwitch; - const handleSelect = (newSetting: AgentSwitchSettings) => { setSetting(newSetting); saveAgentSwitchSettings(newSetting); setIsOpen(false); }; - const agentLabel = shouldShowAgentSwitch ? getSelectedLabel(setting, agents) : null; - const isNoSwitch = shouldShowAgentSwitch && setting.switchTo === 'disabled'; - const isCustom = shouldShowAgentSwitch && setting.switchTo === 'custom'; - const notFound = shouldShowAgentSwitch && agentLabel && !isNoSwitch && !isCustom + const agentLabel = getSelectedLabel(setting, agents); + const isNoSwitch = setting.switchTo === 'disabled'; + const isCustom = setting.switchTo === 'custom'; + const notFound = agentLabel && !isNoSwitch && !isCustom && !agents.some(a => a.id.toLowerCase() === setting.switchTo.toLowerCase()); const baseClasses = disabled @@ -94,36 +78,16 @@ export const ApproveDropdown: React.FC = ({ onApprove(); }; - const handleExtraSelect = (entry: ApproveExtraEntry) => { - if (entry.disabled) return; - setIsOpen(false); - entry.onSelect(); - }; - return (
- {/* Mobile: simple button, with menu when extra actions exist */} -
- - {hasDropdownContent && ( - - )} -
+ {/* Mobile: simple button */} + {/* Desktop: split button */}
@@ -145,9 +109,8 @@ export const ApproveDropdown: React.FC = ({
{/* Dropdown */} - {isOpen && hasDropdownContent && ( -
- {hasExtraEntries && ( - <> - {extraEntries.map((entry) => ( - - ))} - {shouldShowAgentSwitch &&
} - - )} - {shouldShowAgentSwitch && ( - <> -
- Switch to agent -
- {agents.map((agent) => { - const selected = isSelected(agent.id, setting); - return ( - - ); - })} - {isCustom && setting.customName && ( - - )} -
+ {isOpen && ( +
+
+ Switch to agent +
+ {agents.map((agent) => { + const selected = isSelected(agent.id, setting); + return ( - + ); + })} + {isCustom && setting.customName && ( + )} +
+
)}
diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 48a4bdb6f..e2982315b 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -80,7 +80,6 @@ interface SettingsProps { origin?: Origin | null; mode?: 'plan' | 'review'; onUIPreferencesChange?: (prefs: UIPreferences) => void; - onPermissionModeChange?: (mode: PermissionMode) => void; /** Externally controlled open state (for mobile menu integration) */ externalOpen?: boolean; onExternalClose?: () => void; @@ -600,7 +599,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, onPermissionModeChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -804,7 +803,6 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const handlePermissionModeChange = (mode: PermissionMode) => { setPermissionMode(mode); savePermissionModeSettings(mode); - onPermissionModeChange?.(mode); }; const handleDefaultNotesAppChange = (app: DefaultNotesApp) => { diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index 4143c1d75..809995377 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,7 +6,6 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls - * - bypassPermissionsClearReminder: Persisted UI mode that sends bypassPermissions plus a /clear reminder nudge * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -16,7 +15,7 @@ import { storage } from './storage'; const STORAGE_KEY_MODE = 'plannotator-permission-mode'; const STORAGE_KEY_CONFIGURED = 'plannotator-permission-mode-configured'; -export type PermissionMode = 'bypassPermissions' | 'bypassPermissionsClearReminder' | 'acceptEdits' | 'default'; +export type PermissionMode = 'bypassPermissions' | 'acceptEdits' | 'default'; export interface PermissionModeSettings { mode: PermissionMode; @@ -34,11 +33,6 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de label: 'Bypass Permissions', description: 'Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)', }, - { - value: 'bypassPermissionsClearReminder', - label: 'Bypass + /clear Reminder', - description: 'Auto-approve all tool calls and emit a system message reminding you to run /clear (hooks cannot clear context directly).', - }, { value: 'default', label: 'Manual Approval', @@ -48,19 +42,15 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de const DEFAULT_MODE: PermissionMode = 'acceptEdits'; -function isPermissionMode(value: string | null): value is PermissionMode { - return PERMISSION_MODE_OPTIONS.some((option) => option.value === value); -} - /** * Get current permission mode settings from storage */ export function getPermissionModeSettings(): PermissionModeSettings { - const mode = storage.getItem(STORAGE_KEY_MODE); + const mode = storage.getItem(STORAGE_KEY_MODE) as PermissionMode | null; const configured = storage.getItem(STORAGE_KEY_CONFIGURED) === 'true'; return { - mode: isPermissionMode(mode) ? mode : DEFAULT_MODE, + mode: mode || DEFAULT_MODE, configured, }; } From 3e38041ecf8dced6f9cf50d996e8bc34af5d004e Mon Sep 17 00:00:00 2001 From: Graeme Folk <149592200+graemefolk@users.noreply.github.com> Date: Thu, 7 May 2026 20:57:33 -0600 Subject: [PATCH 20/39] feat(review): add jj review workflows (#675) * feat(review): add jj support for local diffs * feat(review): add jj review workflows * fix(review): tighten jj diff defaults * test(review): add jj manual sandbox * fix(review): share jj agent diff prompts * fix(review): quote jj agent revsets * feat(review): share jj vcs handling with pi * fix(review): tighten jj bookmark and pi pr handling * fix(review): tighten jj defaults and detection * fix(review): harden jj diff and vcs detection --------- Co-authored-by: Michael Ramos --- apps/pi-extension/server/serverReview.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 98fa2deaa..fbd710f68 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -1,4 +1,4 @@ -import { execSync, spawn } from "node:child_process"; +import { execSync } from "node:child_process"; import { readFileSync, existsSync } from "node:fs"; import { createServer } from "node:http"; import os from "node:os"; From 86852664c0ff61f11e56a8072992439d2779c4c2 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 08:14:13 -0400 Subject: [PATCH 21/39] feat(hook): PFM reminder & improvement hook support across all runtimes (#689) PFM reminder & improvement hook support across Claude Code, OpenCode, and Pi. - Add opt-in PFM reminder (pfmReminder config flag) injected on EnterPlanMode - Wire composeImproveContext() into all three runtimes - Fix OpenCode system.transform array reference bug (pushes were going to dead array) - Fix install scripts silently stripping PreToolUse/EnterPlanMode hook entry - Isolated Pi sandbox testing (--no-extensions -e) --- apps/hook/server/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index f3cdc2c25..3409a792f 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -76,7 +76,7 @@ import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLa import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { statSync, rmSync, realpathSync, existsSync } from "fs"; +import { statSync, rmSync, realpathSync, existsSync, appendFileSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { getReviewApprovedPrompt, @@ -113,7 +113,7 @@ import { import { ensureClearContextSettingEnabled } from "./clearContextSetting"; import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; -import { tmpdir } from "os"; +import { tmpdir, homedir } from "os"; // Embed the built HTML at compile time // @ts-ignore - Bun import attribute for text From 863d52d93bebd430e7f2c77832b4c24bff2d5ac6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 10:13:47 -0400 Subject: [PATCH 22/39] feat(pfm): code line range references, hover preview, sketch Graphviz (#692) Code file line range references with hover preview + Graphviz improvements. Line ranges: `file.ts:42` and `file.ts:10-20` are fully supported with syntax-highlighted hover preview popover (150ms delay, GitHub-style persistence). New parseCodePath() utility, server-side line suffix stripping on both Bun and Pi servers, ambiguous picker preserves line suffix. useCodeFilePopout moved to hooks/pfm/. Graphviz: responsive container height from SVG aspect ratio, white background polygon removed, default colors (black, lightgrey) replaced with theme tokens via SVG post-processing. User-specified colors preserved. --- apps/hook/server/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 3409a792f..f3cdc2c25 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -76,7 +76,7 @@ import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLa import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { statSync, rmSync, realpathSync, existsSync, appendFileSync } from "fs"; +import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { getReviewApprovedPrompt, @@ -113,7 +113,7 @@ import { import { ensureClearContextSettingEnabled } from "./clearContextSetting"; import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; -import { tmpdir, homedir } from "os"; +import { tmpdir } from "os"; // Embed the built HTML at compile time // @ts-ignore - Bun import attribute for text From 734d75a69b2e2ca4c5c40032694380da3a001f76 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 15:58:06 +0700 Subject: [PATCH 23/39] omx(team): auto-checkpoint worker-4 [unknown] --- apps/hook/server/index.ts | 8 +- apps/hook/server/keystrokeInjector.test.ts | 126 --------------------- apps/hook/server/keystrokeInjector.ts | 54 --------- apps/pi-extension/plannotator-browser.ts | 1 + apps/pi-extension/plannotator-events.ts | 2 + apps/pi-extension/server/serverPlan.ts | 4 + packages/server/index.ts | 20 +++- 7 files changed, 30 insertions(+), 185 deletions(-) delete mode 100644 apps/hook/server/keystrokeInjector.test.ts delete mode 100644 apps/hook/server/keystrokeInjector.ts diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index f3cdc2c25..1bbdb6c7f 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -111,7 +111,6 @@ import { isVersionInvocation, } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; -import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; import path from "path"; import { tmpdir } from "os"; @@ -1281,9 +1280,6 @@ if (args[0] === "sessions") { ) { const nativeClearEnabled = await ensureClearContextSettingEnabled(); if (nativeClearEnabled) { - if (shouldAutoSelectNativeClear()) { - spawnKeystrokeInjector(); - } process.exit(0); } result.clearContextNudge = true; @@ -1302,6 +1298,10 @@ if (args[0] === "sessions") { console.log( JSON.stringify({ + ...(result.clearContextNudge && { + systemMessage: + "Plannotator requested bypass mode. Hooks cannot clear context. Run /clear before continuing if you want a fresh implementation session.", + }), hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts deleted file mode 100644 index 5391a6d94..000000000 --- a/apps/hook/server/keystrokeInjector.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - describe, - it, - expect, - spyOn, - mock, - beforeEach, - afterEach, -} from "bun:test"; -import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; - -describe("shouldAutoSelectNativeClear", () => { - it("defaults to false", () => { - expect(shouldAutoSelectNativeClear({} as NodeJS.ProcessEnv)).toBe(false); - }); - - it("is true only for PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR=1", () => { - expect( - shouldAutoSelectNativeClear({ - PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "1", - } as NodeJS.ProcessEnv), - ).toBe(true); - expect( - shouldAutoSelectNativeClear({ - PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "true", - } as NodeJS.ProcessEnv), - ).toBe(false); - }); -}); - -describe("spawnKeystrokeInjector", () => { - let spawnCalls: { cmd: string[]; opts: Record }[] = []; - let originalEnv: NodeJS.ProcessEnv; - let originalPlatform: string; - - function setPlatform(v: string) { - Object.defineProperty(process, "platform", { value: v, writable: true }); - } - - beforeEach(() => { - spawnCalls = []; - originalEnv = { ...process.env }; - originalPlatform = process.platform; - delete process.env["TMUX_PANE"]; - - const mockChild = { unref: mock(() => {}) }; - spyOn(Bun, "spawn").mockImplementation( - (cmd: string[], opts: Record) => { - spawnCalls.push({ cmd, opts }); - return mockChild as ReturnType; - }, - ); - }); - - afterEach(() => { - process.env = originalEnv; - Object.defineProperty(process, "platform", { - value: originalPlatform, - writable: true, - }); - mock.restore(); - }); - - it("uses tmux send-keys when TMUX_PANE is set", () => { - process.env["TMUX_PANE"] = "%3"; - spawnKeystrokeInjector(100); - - expect(spawnCalls).toHaveLength(1); - expect(spawnCalls[0].cmd[0]).toBe("bash"); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("tmux send-keys"); - expect(script).toContain("%3"); - expect(script).toContain("1 Enter"); - expect(script).not.toContain("osascript"); - }); - - it("uses osascript on macOS when no tmux pane", () => { - setPlatform("darwin"); - spawnKeystrokeInjector(100); - - expect(spawnCalls).toHaveLength(1); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("osascript"); - expect(script).toContain('application "Warp" is running'); - expect(script).toContain("iTerm2"); - expect(script).toContain("Terminal"); - expect(script).toContain('keystroke "1"'); - expect(script).not.toContain("tmux send-keys"); - }); - - it("no-op on linux without tmux", () => { - setPlatform("linux"); - spawnKeystrokeInjector(); - expect(spawnCalls).toHaveLength(0); - }); - - it("no-op on windows without tmux", () => { - setPlatform("win32"); - spawnKeystrokeInjector(); - expect(spawnCalls).toHaveLength(0); - }); - - it("spawn is detached and unreffed (does not block caller)", () => { - setPlatform("darwin"); - spawnKeystrokeInjector(); - - expect(spawnCalls).toHaveLength(1); - const opts = spawnCalls[0].opts as { detached: boolean; stdio: string[] }; - expect(opts.detached).toBe(true); - expect(opts.stdio).toEqual(["ignore", "ignore", "ignore"]); - }); - - it("embeds delay in tmux script via sleep", () => { - process.env["TMUX_PANE"] = "%0"; - spawnKeystrokeInjector(1200); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("sleep 1.20"); - }); - - it("embeds delay in osascript via 'delay' statement", () => { - setPlatform("darwin"); - spawnKeystrokeInjector(800); - const script = spawnCalls[0].cmd[2] as string; - expect(script).toContain("delay 0.80"); - }); -}); diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts deleted file mode 100644 index 81bc816f8..000000000 --- a/apps/hook/server/keystrokeInjector.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Opt-in helper that injects "1\n" into CC terminal to auto-select native clear + bypass. -const MACOS_PROCESS_TERMINALS = ["iTerm2", "Terminal"]; - -export function shouldAutoSelectNativeClear(env: NodeJS.ProcessEnv = process.env): boolean { - return env["PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR"] === "1"; -} - -export function spawnKeystrokeInjector(delayMs = 600): void { - const delaySec = (delayMs / 1000).toFixed(2); - const tmuxPane = process.env["TMUX_PANE"]; - - let script: string | null = null; - - if (tmuxPane) { - script = `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(tmuxPane)} 1 Enter`; - } else if (process.platform === "darwin") { - const apps = MACOS_PROCESS_TERMINALS.map((a) => `"${a}"`).join(", "); - // Warp ships as Warp.app/MacOS/stable so its process name is "stable", not "warp". - // Check by bundle name first, then fall back to process-name search for other terminals. - script = [ - `osascript <<'APPLESCRIPT'`, - `delay ${delaySec}`, - `if application "Warp" is running then`, - ` tell application "Warp" to activate`, - ` delay 0.05`, - ` tell application "System Events"`, - ` keystroke "1"`, - ` key code 36`, - ` end tell`, - `else`, - ` tell application "System Events"`, - ` repeat with appName in {${apps}}`, - ` if exists (application process (appName as string)) then`, - ` set frontmost of application process (appName as string) to true`, - ` delay 0.05`, - ` keystroke "1"`, - ` key code 36`, - ` exit repeat`, - ` end if`, - ` end repeat`, - ` end tell`, - `end if`, - `APPLESCRIPT`, - ].join("\n"); - } - - if (!script) return; // Linux/Windows without tmux: user must press 1 manually - - const child = Bun.spawn(["bash", "-c", script], { - detached: true, - stdio: ["ignore", "ignore", "ignore"], - }); - child.unref(); -} diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index 2235bca6f..a47e89a8f 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -35,6 +35,7 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface BrowserDecisionSession { diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index 12845ae70..f2754a6de 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -73,6 +73,7 @@ export interface PlannotatorReviewResultEvent { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlannotatorReviewStatusPayload { @@ -248,6 +249,7 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { savedPath: result.savedPath, agentSwitch: result.agentSwitch, permissionMode: result.permissionMode, + clearContextNudge: result.clearContextNudge, } satisfies PlannotatorReviewResultEvent; setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult }); pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index e28e88dc3..5db3c6507 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -62,6 +62,7 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlanServerResult { @@ -375,6 +376,7 @@ export async function startPlanReviewServer(options: { let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; + let clearContextNudge: boolean | undefined; let planSaveEnabled = true; let planSaveCustomPath: string | undefined; try { @@ -383,6 +385,7 @@ export async function startPlanReviewServer(options: { if (body.agentSwitch) agentSwitch = body.agentSwitch as string; if (body.permissionMode) requestedPermissionMode = body.permissionMode as string; + if (body.clearContextNudge === true) clearContextNudge = true; if (body.planSave !== undefined) { const ps = body.planSave as { enabled: boolean; customPath?: string }; planSaveEnabled = ps.enabled; @@ -444,6 +447,7 @@ export async function startPlanReviewServer(options: { savedPath, agentSwitch, permissionMode: effectivePermissionMode, + clearContextNudge, }); json(res, { ok: true, savedPath }); } else if ( diff --git a/packages/server/index.ts b/packages/server/index.ts index f17a233c6..b6a6501e7 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -110,6 +110,8 @@ export interface ServerResult { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; /** Wait for user to close (archive mode only) */ waitForDone?: () => Promise; @@ -239,6 +241,8 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -246,6 +250,8 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; if (mode !== "archive") { @@ -522,6 +528,8 @@ export async function startPlannotatorServer( let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; + let clearContextNudge: boolean | undefined; + let deferToNativeForClear: boolean | undefined; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; try { @@ -533,6 +541,8 @@ export async function startPlannotatorServer( agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }; // Capture feedback if provided (for "approve with notes") @@ -550,6 +560,14 @@ export async function startPlannotatorServer( requestedPermissionMode = body.permissionMode; } + // Capture optional /clear reminder request for Claude Code approval flow + if (body.clearContextNudge === true) { + clearContextNudge = true; + } + if (body.deferToNativeForClear === true) { + deferToNativeForClear = true; + } + // Capture plan save settings if (body.planSave !== undefined) { planSaveEnabled = body.planSave.enabled; @@ -595,7 +613,7 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode }); + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, clearContextNudge, deferToNativeForClear }); return Response.json({ ok: true, savedPath }); } From 933544a27f6aa06db551ba8cc5a80cbcbadffadb Mon Sep 17 00:00:00 2001 From: Nehr <127654909+AgileInnov8tor@users.noreply.github.com> Date: Wed, 6 May 2026 02:27:16 +0700 Subject: [PATCH 24/39] Expose bypass clear reminder permission mode (#668) * Preserve truthful approval semantics for Claude plan bypass Thread a clear-context reminder flag through approval decisions, expose a Claude Code-only approval entry that requests bypass mode, and keep the hook response honest by emitting a reminder instead of claiming context was cleared. Constraint: Claude Code PermissionRequest hooks have no documented clearContext response field and bypassPermissions is only a request when the mode is available. Rejected: adding a permission-mode enum or undocumented clearContext field | those would misrepresent hook capabilities and broaden the contract. Confidence: high Scope-risk: moderate Directive: Do not claim Plannotator clears context until Claude Code documents a hook field for that behavior; keep reminder copy truthful. Tested: bun test packages/server apps/hook; bun test packages/editor/wideMode.test.ts packages/ui/hooks/useAgentSettings.test.ts; bun test; bun run build:review; bun run build:hook; bun run typecheck; git diff --check --cached. Not-tested: Browser warning-dialog replay and interactive Claude Code smoke test. Co-authored-by: OmX * Keep approval payloads truthful across Claude Code and Pi Constraint: Claude Code hooks cannot clear context directly; the new action remains a truthful reminder plus bypass-mode approval. Rejected: Sending OpenCode agent-switch state for Claude Code | it leaked build (?) UI state and misleading payload fields. Confidence: high Scope-risk: narrow Directive: Keep OpenCode agent switching gated to opencode-origin approval payloads. Tested: bun run typecheck; bun test; bun run build:review; bun run build:hook Not-tested: Manual browser click-through of the Claude Code dropdown. * Expose the clear-context reminder permission default Constraint: Claude Code hooks can only emit a systemMessage nudge, not clear context directly. Rejected: Server protocol changes | existing permissionMode plus clearContextNudge wire fields already support the behavior. Confidence: high Scope-risk: narrow Directive: Keep bypassPermissionsClearReminder as a UI/storage-only synthetic mode that decomposes before /api/approve. Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd apps/review build && bun run build:hook; bun run --cwd packages/ui typecheck; git diff --check Not-tested: Root bun run typecheck could not run because tsc was not on PATH for the root script. * Reject invalid persisted permission modes Constraint: Stored browser values can be stale, corrupt, or from a future Plannotator build. Rejected: Trusting the storage read with a type assertion | invalid values could flow into UI state and approval request construction. Confidence: high Scope-risk: narrow Directive: Keep PermissionMode storage reads validated against PERMISSION_MODE_OPTIONS when adding or renaming modes. Tested: bun test packages/editor/approvalBody.test.ts; bun run --cwd packages/ui typecheck; git diff --check Not-tested: Full repo typecheck/build, outside this review-fix scope. * Ensure approvals use the live permission setting Settings persisted permission mode changes, but App kept the original mode in React state and used that stale value when building approval payloads. Push the Settings change back into App so the selected clear-reminder mode reaches the hook decision. Constraint: Permission mode storage is cookie-backed while approval payload construction reads App state.\nRejected: Read permission cookies during approval | would couple approval construction to browser storage and duplicate Settings state.\nConfidence: high\nScope-risk: narrow\nDirective: Keep permission-mode writes synchronized with approval state when adding or changing modes.\nTested: bun test packages/editor/approvalBody.test.ts packages/ui/components/ApproveDropdown.test.tsx; bun x tsc --noEmit -p packages/ui/tsconfig.json; bun run build:hook; direct Playwright hook smoke verified bypassPermissionsClearReminder UI and hook payload.\nNot-tested: Live interactive Claude CLI session; direct hook/server simulation covered the wire output. --------- Co-authored-by: OmX --- packages/editor/App.tsx | 41 +++-- packages/editor/approvalBody.test.ts | 72 ++++++++ packages/editor/approvalBody.ts | 50 ++++++ .../ui/components/ApproveDropdown.test.tsx | 24 +++ packages/ui/components/ApproveDropdown.tsx | 157 ++++++++++++------ packages/ui/components/Settings.tsx | 4 +- packages/ui/utils/permissionMode.ts | 16 +- 7 files changed, 300 insertions(+), 64 deletions(-) create mode 100644 packages/editor/approvalBody.test.ts create mode 100644 packages/editor/approvalBody.ts create mode 100644 packages/ui/components/ApproveDropdown.test.tsx diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 25d64af52..53e94bc82 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,6 +78,7 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; +import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -105,6 +106,7 @@ const App: React.FC = () => { const [showImport, setShowImport] = useState(false); const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false); + const [pendingApprovalOverride, setPendingApprovalOverride] = useState(null); const [showExitWarning, setShowExitWarning] = useState(false); // When the warning dialog confirms, route to the handler matching the button that opened it. const [exitWarningAction, setExitWarningAction] = useState<'close' | 'approve'>('close'); @@ -951,7 +953,8 @@ const App: React.FC = () => { }; // API mode handlers - const handleApprove = async () => { + const handleApprove = async (override: ApprovalOverride = {}) => { + setPendingApprovalOverride(null); setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); @@ -962,14 +965,6 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - // Build request body - include integrations if enabled - const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {}; - - // Include permission mode for Claude Code - if (origin === 'claude-code') { - body.permissionMode = permissionMode; - } - const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -1048,6 +1043,25 @@ const App: React.FC = () => { } }; + const approveWithClaudeCodeWarning = useCallback((override: ApprovalOverride = {}) => { + setPendingApprovalOverride(override); + if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { + setShowClaudeCodeWarning(true); + return; + } + handleApprove(override); + }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); + + const claudeCodeExtraEntries = useMemo(() => (origin === 'claude-code' ? [{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly.', + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }), + }] : []), [approveWithClaudeCodeWarning, origin]); + // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); @@ -2197,10 +2211,15 @@ const App: React.FC = () => { {/* Claude Code annotation warning dialog */} setShowClaudeCodeWarning(false)} + onClose={() => { + setShowClaudeCodeWarning(false); + setPendingApprovalOverride(null); + }} onConfirm={() => { + const override = pendingApprovalOverride ?? {}; setShowClaudeCodeWarning(false); - handleApprove(); + setPendingApprovalOverride(null); + handleApprove(override); }} title="Annotations Won't Be Sent" message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.} diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts new file mode 100644 index 000000000..dcb9a2451 --- /dev/null +++ b/packages/editor/approvalBody.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test'; +import { buildApprovalRequestBody } from './approvalBody'; + +describe('buildApprovalRequestBody', () => { + test('maps bypass clear reminder mode to Claude Code wire fields', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('omits agentSwitch for Claude Code approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + override: { + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('keeps bypass clear reminder override wire fields for Claude Code approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + override: { + permissionMode: 'bypassPermissionsClearReminder', + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('keeps agentSwitch for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); + + test('ignores bypass clear reminder mode for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'bypassPermissionsClearReminder', + effectiveAgent: 'build', + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); +}); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts new file mode 100644 index 000000000..5d8e92703 --- /dev/null +++ b/packages/editor/approvalBody.ts @@ -0,0 +1,50 @@ +import type { Origin } from '@plannotator/shared/agents'; +import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; + +export type ApprovalOverride = { + permissionMode?: PermissionMode; + clearContextNudge?: boolean; +}; + +export interface ApprovalRequestBody { + obsidian?: object; + bear?: object; + octarine?: object; + feedback?: string; + agentSwitch?: string; + planSave?: { enabled: boolean; customPath?: string }; + permissionMode?: string; + clearContextNudge?: boolean; +} + +export function buildApprovalRequestBody(options: { + origin: Origin | null; + permissionMode: PermissionMode; + override?: ApprovalOverride; + effectiveAgent?: string; + planSaveSettings: { enabled: boolean; customPath?: string | null }; +}): ApprovalRequestBody { + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; + const body: ApprovalRequestBody = {}; + + if (origin === 'claude-code') { + const effectivePermissionMode = override.permissionMode ?? permissionMode; + body.permissionMode = effectivePermissionMode === 'bypassPermissionsClearReminder' + ? 'bypassPermissions' + : effectivePermissionMode; + if (override.clearContextNudge || effectivePermissionMode === 'bypassPermissionsClearReminder') { + body.clearContextNudge = true; + } + } + + if (origin === 'opencode' && effectiveAgent) { + body.agentSwitch = effectiveAgent; + } + + body.planSave = { + enabled: planSaveSettings.enabled, + ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), + }; + + return body; +} diff --git a/packages/ui/components/ApproveDropdown.test.tsx b/packages/ui/components/ApproveDropdown.test.tsx new file mode 100644 index 000000000..967f8e4be --- /dev/null +++ b/packages/ui/components/ApproveDropdown.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { describe, expect, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ApproveDropdown } from './ApproveDropdown'; + +describe('ApproveDropdown', () => { + test('does not show agent-switch label when only extra approval entries are enabled', () => { + const html = renderToStaticMarkup( + {}} + agents={[]} + extraEntries={[{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + onSelect: () => {}, + }]} + />, + ); + + expect(html).toContain('Approve'); + expect(html).not.toContain('build'); + expect(html).not.toContain('(?)'); + }); +}); diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index ab9c48c7f..9424696d3 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -2,11 +2,21 @@ import React, { useState, useRef, useEffect } from 'react'; import type { Agent } from '../hooks/useAgents'; import { getAgentSwitchSettings, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch'; +export interface ApproveExtraEntry { + id: string; + label: string; + description?: string; + onSelect: () => void; + disabled?: boolean; +} + interface ApproveDropdownProps { onApprove: () => void; agents: Agent[]; disabled?: boolean; isLoading?: boolean; + extraEntries?: ApproveExtraEntry[]; + showAgentSwitch?: boolean; } function getSelectedLabel(setting: AgentSwitchSettings, agents: Agent[]): string | null { @@ -35,6 +45,8 @@ export const ApproveDropdown: React.FC = ({ agents, disabled = false, isLoading = false, + extraEntries = [], + showAgentSwitch, }) => { const [setting, setSetting] = useState(() => getAgentSwitchSettings()); const [isOpen, setIsOpen] = useState(false); @@ -57,16 +69,20 @@ export const ApproveDropdown: React.FC = ({ }; }, []); + const hasExtraEntries = extraEntries.length > 0; + const shouldShowAgentSwitch = showAgentSwitch ?? agents.length > 0; + const hasDropdownContent = hasExtraEntries || shouldShowAgentSwitch; + const handleSelect = (newSetting: AgentSwitchSettings) => { setSetting(newSetting); saveAgentSwitchSettings(newSetting); setIsOpen(false); }; - const agentLabel = getSelectedLabel(setting, agents); - const isNoSwitch = setting.switchTo === 'disabled'; - const isCustom = setting.switchTo === 'custom'; - const notFound = agentLabel && !isNoSwitch && !isCustom + const agentLabel = shouldShowAgentSwitch ? getSelectedLabel(setting, agents) : null; + const isNoSwitch = shouldShowAgentSwitch && setting.switchTo === 'disabled'; + const isCustom = shouldShowAgentSwitch && setting.switchTo === 'custom'; + const notFound = shouldShowAgentSwitch && agentLabel && !isNoSwitch && !isCustom && !agents.some(a => a.id.toLowerCase() === setting.switchTo.toLowerCase()); const baseClasses = disabled @@ -78,16 +94,36 @@ export const ApproveDropdown: React.FC = ({ onApprove(); }; + const handleExtraSelect = (entry: ApproveExtraEntry) => { + if (entry.disabled) return; + setIsOpen(false); + entry.onSelect(); + }; + return (
- {/* Mobile: simple button */} - + {/* Mobile: simple button, with menu when extra actions exist */} +
+ + {hasDropdownContent && ( + + )} +
{/* Desktop: split button */}
@@ -109,8 +145,9 @@ export const ApproveDropdown: React.FC = ({
{/* Dropdown */} - {isOpen && ( -
-
- Switch to agent -
- {agents.map((agent) => { - const selected = isSelected(agent.id, setting); - return ( + {isOpen && hasDropdownContent && ( +
+ {hasExtraEntries && ( + <> + {extraEntries.map((entry) => ( + + ))} + {shouldShowAgentSwitch &&
} + + )} + {shouldShowAgentSwitch && ( + <> +
+ Switch to agent +
+ {agents.map((agent) => { + const selected = isSelected(agent.id, setting); + return ( + + ); + })} + {isCustom && setting.customName && ( + + )} +
- ); - })} - {isCustom && setting.customName && ( - + )} -
-
)}
diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index e2982315b..48a4bdb6f 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -80,6 +80,7 @@ interface SettingsProps { origin?: Origin | null; mode?: 'plan' | 'review'; onUIPreferencesChange?: (prefs: UIPreferences) => void; + onPermissionModeChange?: (mode: PermissionMode) => void; /** Externally controlled open state (for mobile menu integration) */ externalOpen?: boolean; onExternalClose?: () => void; @@ -599,7 +600,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, onPermissionModeChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -803,6 +804,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const handlePermissionModeChange = (mode: PermissionMode) => { setPermissionMode(mode); savePermissionModeSettings(mode); + onPermissionModeChange?.(mode); }; const handleDefaultNotesAppChange = (app: DefaultNotesApp) => { diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index 809995377..4143c1d75 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,6 +6,7 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls + * - bypassPermissionsClearReminder: Persisted UI mode that sends bypassPermissions plus a /clear reminder nudge * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -15,7 +16,7 @@ import { storage } from './storage'; const STORAGE_KEY_MODE = 'plannotator-permission-mode'; const STORAGE_KEY_CONFIGURED = 'plannotator-permission-mode-configured'; -export type PermissionMode = 'bypassPermissions' | 'acceptEdits' | 'default'; +export type PermissionMode = 'bypassPermissions' | 'bypassPermissionsClearReminder' | 'acceptEdits' | 'default'; export interface PermissionModeSettings { mode: PermissionMode; @@ -33,6 +34,11 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de label: 'Bypass Permissions', description: 'Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)', }, + { + value: 'bypassPermissionsClearReminder', + label: 'Bypass + /clear Reminder', + description: 'Auto-approve all tool calls and emit a system message reminding you to run /clear (hooks cannot clear context directly).', + }, { value: 'default', label: 'Manual Approval', @@ -42,15 +48,19 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de const DEFAULT_MODE: PermissionMode = 'acceptEdits'; +function isPermissionMode(value: string | null): value is PermissionMode { + return PERMISSION_MODE_OPTIONS.some((option) => option.value === value); +} + /** * Get current permission mode settings from storage */ export function getPermissionModeSettings(): PermissionModeSettings { - const mode = storage.getItem(STORAGE_KEY_MODE) as PermissionMode | null; + const mode = storage.getItem(STORAGE_KEY_MODE); const configured = storage.getItem(STORAGE_KEY_CONFIGURED) === 'true'; return { - mode: mode || DEFAULT_MODE, + mode: isPermissionMode(mode) ? mode : DEFAULT_MODE, configured, }; } From ba50652c386858ebd9a78575c207db514b0a4a40 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 6 May 2026 16:10:31 -0700 Subject: [PATCH 25/39] Revert "Expose bypass clear reminder permission mode (#668)" This reverts commit 3b88415aeb0276162bfd6a57958e1f4d599c0c98. --- apps/hook/server/index.ts | 4 - apps/pi-extension/plannotator-browser.ts | 1 - apps/pi-extension/plannotator-events.ts | 2 - apps/pi-extension/server/serverPlan.ts | 4 - packages/editor/App.tsx | 41 ++--- packages/editor/approvalBody.test.ts | 72 -------- packages/editor/approvalBody.ts | 50 ------ .../ui/components/ApproveDropdown.test.tsx | 24 --- packages/ui/components/ApproveDropdown.tsx | 157 ++++++------------ packages/ui/components/Settings.tsx | 4 +- packages/ui/utils/permissionMode.ts | 16 +- 11 files changed, 64 insertions(+), 311 deletions(-) delete mode 100644 packages/editor/approvalBody.test.ts delete mode 100644 packages/editor/approvalBody.ts delete mode 100644 packages/ui/components/ApproveDropdown.test.tsx diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 1bbdb6c7f..4e3f4e1fa 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1298,10 +1298,6 @@ if (args[0] === "sessions") { console.log( JSON.stringify({ - ...(result.clearContextNudge && { - systemMessage: - "Plannotator requested bypass mode. Hooks cannot clear context. Run /clear before continuing if you want a fresh implementation session.", - }), hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index a47e89a8f..2235bca6f 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -35,7 +35,6 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; } export interface BrowserDecisionSession { diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index f2754a6de..12845ae70 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -73,7 +73,6 @@ export interface PlannotatorReviewResultEvent { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; } export interface PlannotatorReviewStatusPayload { @@ -249,7 +248,6 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { savedPath: result.savedPath, agentSwitch: result.agentSwitch, permissionMode: result.permissionMode, - clearContextNudge: result.clearContextNudge, } satisfies PlannotatorReviewResultEvent; setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult }); pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 5db3c6507..e28e88dc3 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -62,7 +62,6 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; - clearContextNudge?: boolean; } export interface PlanServerResult { @@ -376,7 +375,6 @@ export async function startPlanReviewServer(options: { let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; - let clearContextNudge: boolean | undefined; let planSaveEnabled = true; let planSaveCustomPath: string | undefined; try { @@ -385,7 +383,6 @@ export async function startPlanReviewServer(options: { if (body.agentSwitch) agentSwitch = body.agentSwitch as string; if (body.permissionMode) requestedPermissionMode = body.permissionMode as string; - if (body.clearContextNudge === true) clearContextNudge = true; if (body.planSave !== undefined) { const ps = body.planSave as { enabled: boolean; customPath?: string }; planSaveEnabled = ps.enabled; @@ -447,7 +444,6 @@ export async function startPlanReviewServer(options: { savedPath, agentSwitch, permissionMode: effectivePermissionMode, - clearContextNudge, }); json(res, { ok: true, savedPath }); } else if ( diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 53e94bc82..25d64af52 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,7 +78,6 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; -import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -106,7 +105,6 @@ const App: React.FC = () => { const [showImport, setShowImport] = useState(false); const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false); - const [pendingApprovalOverride, setPendingApprovalOverride] = useState(null); const [showExitWarning, setShowExitWarning] = useState(false); // When the warning dialog confirms, route to the handler matching the button that opened it. const [exitWarningAction, setExitWarningAction] = useState<'close' | 'approve'>('close'); @@ -953,8 +951,7 @@ const App: React.FC = () => { }; // API mode handlers - const handleApprove = async (override: ApprovalOverride = {}) => { - setPendingApprovalOverride(null); + const handleApprove = async () => { setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); @@ -965,6 +962,14 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; + // Build request body - include integrations if enabled + const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {}; + + // Include permission mode for Claude Code + if (origin === 'claude-code') { + body.permissionMode = permissionMode; + } + const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -1043,25 +1048,6 @@ const App: React.FC = () => { } }; - const approveWithClaudeCodeWarning = useCallback((override: ApprovalOverride = {}) => { - setPendingApprovalOverride(override); - if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { - setShowClaudeCodeWarning(true); - return; - } - handleApprove(override); - }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); - - const claudeCodeExtraEntries = useMemo(() => (origin === 'claude-code' ? [{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly.', - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }), - }] : []), [approveWithClaudeCodeWarning, origin]); - // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); @@ -2211,15 +2197,10 @@ const App: React.FC = () => { {/* Claude Code annotation warning dialog */} { - setShowClaudeCodeWarning(false); - setPendingApprovalOverride(null); - }} + onClose={() => setShowClaudeCodeWarning(false)} onConfirm={() => { - const override = pendingApprovalOverride ?? {}; setShowClaudeCodeWarning(false); - setPendingApprovalOverride(null); - handleApprove(override); + handleApprove(); }} title="Annotations Won't Be Sent" message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.} diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts deleted file mode 100644 index dcb9a2451..000000000 --- a/packages/editor/approvalBody.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { buildApprovalRequestBody } from './approvalBody'; - -describe('buildApprovalRequestBody', () => { - test('maps bypass clear reminder mode to Claude Code wire fields', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'bypassPermissionsClearReminder', - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('omits agentSwitch for Claude Code approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - effectiveAgent: 'build', - override: { - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('keeps bypass clear reminder override wire fields for Claude Code approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'claude-code', - permissionMode: 'acceptEdits', - override: { - permissionMode: 'bypassPermissionsClearReminder', - }, - planSaveSettings: { enabled: true }, - })).toEqual({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - planSave: { enabled: true }, - }); - }); - - test('keeps agentSwitch for OpenCode approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'opencode', - permissionMode: 'acceptEdits', - effectiveAgent: 'build', - planSaveSettings: { enabled: true }, - })).toEqual({ - agentSwitch: 'build', - planSave: { enabled: true }, - }); - }); - - test('ignores bypass clear reminder mode for OpenCode approvals', () => { - expect(buildApprovalRequestBody({ - origin: 'opencode', - permissionMode: 'bypassPermissionsClearReminder', - effectiveAgent: 'build', - planSaveSettings: { enabled: true }, - })).toEqual({ - agentSwitch: 'build', - planSave: { enabled: true }, - }); - }); -}); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts deleted file mode 100644 index 5d8e92703..000000000 --- a/packages/editor/approvalBody.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Origin } from '@plannotator/shared/agents'; -import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; - -export type ApprovalOverride = { - permissionMode?: PermissionMode; - clearContextNudge?: boolean; -}; - -export interface ApprovalRequestBody { - obsidian?: object; - bear?: object; - octarine?: object; - feedback?: string; - agentSwitch?: string; - planSave?: { enabled: boolean; customPath?: string }; - permissionMode?: string; - clearContextNudge?: boolean; -} - -export function buildApprovalRequestBody(options: { - origin: Origin | null; - permissionMode: PermissionMode; - override?: ApprovalOverride; - effectiveAgent?: string; - planSaveSettings: { enabled: boolean; customPath?: string | null }; -}): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; - const body: ApprovalRequestBody = {}; - - if (origin === 'claude-code') { - const effectivePermissionMode = override.permissionMode ?? permissionMode; - body.permissionMode = effectivePermissionMode === 'bypassPermissionsClearReminder' - ? 'bypassPermissions' - : effectivePermissionMode; - if (override.clearContextNudge || effectivePermissionMode === 'bypassPermissionsClearReminder') { - body.clearContextNudge = true; - } - } - - if (origin === 'opencode' && effectiveAgent) { - body.agentSwitch = effectiveAgent; - } - - body.planSave = { - enabled: planSaveSettings.enabled, - ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), - }; - - return body; -} diff --git a/packages/ui/components/ApproveDropdown.test.tsx b/packages/ui/components/ApproveDropdown.test.tsx deleted file mode 100644 index 967f8e4be..000000000 --- a/packages/ui/components/ApproveDropdown.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { describe, expect, test } from 'bun:test'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { ApproveDropdown } from './ApproveDropdown'; - -describe('ApproveDropdown', () => { - test('does not show agent-switch label when only extra approval entries are enabled', () => { - const html = renderToStaticMarkup( - {}} - agents={[]} - extraEntries={[{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - onSelect: () => {}, - }]} - />, - ); - - expect(html).toContain('Approve'); - expect(html).not.toContain('build'); - expect(html).not.toContain('(?)'); - }); -}); diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index 9424696d3..ab9c48c7f 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -2,21 +2,11 @@ import React, { useState, useRef, useEffect } from 'react'; import type { Agent } from '../hooks/useAgents'; import { getAgentSwitchSettings, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch'; -export interface ApproveExtraEntry { - id: string; - label: string; - description?: string; - onSelect: () => void; - disabled?: boolean; -} - interface ApproveDropdownProps { onApprove: () => void; agents: Agent[]; disabled?: boolean; isLoading?: boolean; - extraEntries?: ApproveExtraEntry[]; - showAgentSwitch?: boolean; } function getSelectedLabel(setting: AgentSwitchSettings, agents: Agent[]): string | null { @@ -45,8 +35,6 @@ export const ApproveDropdown: React.FC = ({ agents, disabled = false, isLoading = false, - extraEntries = [], - showAgentSwitch, }) => { const [setting, setSetting] = useState(() => getAgentSwitchSettings()); const [isOpen, setIsOpen] = useState(false); @@ -69,20 +57,16 @@ export const ApproveDropdown: React.FC = ({ }; }, []); - const hasExtraEntries = extraEntries.length > 0; - const shouldShowAgentSwitch = showAgentSwitch ?? agents.length > 0; - const hasDropdownContent = hasExtraEntries || shouldShowAgentSwitch; - const handleSelect = (newSetting: AgentSwitchSettings) => { setSetting(newSetting); saveAgentSwitchSettings(newSetting); setIsOpen(false); }; - const agentLabel = shouldShowAgentSwitch ? getSelectedLabel(setting, agents) : null; - const isNoSwitch = shouldShowAgentSwitch && setting.switchTo === 'disabled'; - const isCustom = shouldShowAgentSwitch && setting.switchTo === 'custom'; - const notFound = shouldShowAgentSwitch && agentLabel && !isNoSwitch && !isCustom + const agentLabel = getSelectedLabel(setting, agents); + const isNoSwitch = setting.switchTo === 'disabled'; + const isCustom = setting.switchTo === 'custom'; + const notFound = agentLabel && !isNoSwitch && !isCustom && !agents.some(a => a.id.toLowerCase() === setting.switchTo.toLowerCase()); const baseClasses = disabled @@ -94,36 +78,16 @@ export const ApproveDropdown: React.FC = ({ onApprove(); }; - const handleExtraSelect = (entry: ApproveExtraEntry) => { - if (entry.disabled) return; - setIsOpen(false); - entry.onSelect(); - }; - return (
- {/* Mobile: simple button, with menu when extra actions exist */} -
- - {hasDropdownContent && ( - - )} -
+ {/* Mobile: simple button */} + {/* Desktop: split button */}
@@ -145,9 +109,8 @@ export const ApproveDropdown: React.FC = ({
{/* Dropdown */} - {isOpen && hasDropdownContent && ( -
- {hasExtraEntries && ( - <> - {extraEntries.map((entry) => ( - - ))} - {shouldShowAgentSwitch &&
} - - )} - {shouldShowAgentSwitch && ( - <> -
- Switch to agent -
- {agents.map((agent) => { - const selected = isSelected(agent.id, setting); - return ( - - ); - })} - {isCustom && setting.customName && ( - - )} -
+ {isOpen && ( +
+
+ Switch to agent +
+ {agents.map((agent) => { + const selected = isSelected(agent.id, setting); + return ( - + ); + })} + {isCustom && setting.customName && ( + )} +
+
)}
diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 48a4bdb6f..e2982315b 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -80,7 +80,6 @@ interface SettingsProps { origin?: Origin | null; mode?: 'plan' | 'review'; onUIPreferencesChange?: (prefs: UIPreferences) => void; - onPermissionModeChange?: (mode: PermissionMode) => void; /** Externally controlled open state (for mobile menu integration) */ externalOpen?: boolean; onExternalClose?: () => void; @@ -600,7 +599,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, onPermissionModeChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -804,7 +803,6 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const handlePermissionModeChange = (mode: PermissionMode) => { setPermissionMode(mode); savePermissionModeSettings(mode); - onPermissionModeChange?.(mode); }; const handleDefaultNotesAppChange = (app: DefaultNotesApp) => { diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index 4143c1d75..809995377 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,7 +6,6 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls - * - bypassPermissionsClearReminder: Persisted UI mode that sends bypassPermissions plus a /clear reminder nudge * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -16,7 +15,7 @@ import { storage } from './storage'; const STORAGE_KEY_MODE = 'plannotator-permission-mode'; const STORAGE_KEY_CONFIGURED = 'plannotator-permission-mode-configured'; -export type PermissionMode = 'bypassPermissions' | 'bypassPermissionsClearReminder' | 'acceptEdits' | 'default'; +export type PermissionMode = 'bypassPermissions' | 'acceptEdits' | 'default'; export interface PermissionModeSettings { mode: PermissionMode; @@ -34,11 +33,6 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de label: 'Bypass Permissions', description: 'Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)', }, - { - value: 'bypassPermissionsClearReminder', - label: 'Bypass + /clear Reminder', - description: 'Auto-approve all tool calls and emit a system message reminding you to run /clear (hooks cannot clear context directly).', - }, { value: 'default', label: 'Manual Approval', @@ -48,19 +42,15 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de const DEFAULT_MODE: PermissionMode = 'acceptEdits'; -function isPermissionMode(value: string | null): value is PermissionMode { - return PERMISSION_MODE_OPTIONS.some((option) => option.value === value); -} - /** * Get current permission mode settings from storage */ export function getPermissionModeSettings(): PermissionModeSettings { - const mode = storage.getItem(STORAGE_KEY_MODE); + const mode = storage.getItem(STORAGE_KEY_MODE) as PermissionMode | null; const configured = storage.getItem(STORAGE_KEY_CONFIGURED) === 'true'; return { - mode: isPermissionMode(mode) ? mode : DEFAULT_MODE, + mode: mode || DEFAULT_MODE, configured, }; } From 8201366a202448296e3baffcb229cb95004618a8 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 08:14:13 -0400 Subject: [PATCH 26/39] feat(hook): PFM reminder & improvement hook support across all runtimes (#689) PFM reminder & improvement hook support across Claude Code, OpenCode, and Pi. - Add opt-in PFM reminder (pfmReminder config flag) injected on EnterPlanMode - Wire composeImproveContext() into all three runtimes - Fix OpenCode system.transform array reference bug (pushes were going to dead array) - Fix install scripts silently stripping PreToolUse/EnterPlanMode hook entry - Isolated Pi sandbox testing (--no-extensions -e) --- apps/hook/server/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 4e3f4e1fa..61f93b3d7 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -76,7 +76,7 @@ import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLa import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { statSync, rmSync, realpathSync, existsSync } from "fs"; +import { statSync, rmSync, realpathSync, existsSync, appendFileSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { getReviewApprovedPrompt, @@ -112,7 +112,7 @@ import { } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; import path from "path"; -import { tmpdir } from "os"; +import { tmpdir, homedir } from "os"; // Embed the built HTML at compile time // @ts-ignore - Bun import attribute for text From cc843922c1e07c0517eb35b3d041003215fd273a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 10:13:47 -0400 Subject: [PATCH 27/39] feat(pfm): code line range references, hover preview, sketch Graphviz (#692) Code file line range references with hover preview + Graphviz improvements. Line ranges: `file.ts:42` and `file.ts:10-20` are fully supported with syntax-highlighted hover preview popover (150ms delay, GitHub-style persistence). New parseCodePath() utility, server-side line suffix stripping on both Bun and Pi servers, ambiguous picker preserves line suffix. useCodeFilePopout moved to hooks/pfm/. Graphviz: responsive container height from SVG aspect ratio, white background polygon removed, default colors (black, lightgrey) replaced with theme tokens via SVG post-processing. User-specified colors preserved. --- apps/hook/server/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 61f93b3d7..4e3f4e1fa 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -76,7 +76,7 @@ import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLa import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { statSync, rmSync, realpathSync, existsSync, appendFileSync } from "fs"; +import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { getReviewApprovedPrompt, @@ -112,7 +112,7 @@ import { } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; import path from "path"; -import { tmpdir, homedir } from "os"; +import { tmpdir } from "os"; // Embed the built HTML at compile time // @ts-ignore - Bun import attribute for text From fe70c47db049a04ded0e2128dc564d9705b413b1 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 10:30:49 -0400 Subject: [PATCH 28/39] feat: standalone skills package + HTML render-annotate mode (#687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --render-html flag to plannotator annotate that renders HTML files as-is in an iframe instead of converting to markdown. Includes annotation support via postMessage bridge, sharing via paste service, and theme inheritance from Plannotator's 30+ themes. New skill: plannotator-visual-explainer — wraps nicobailon/visual-explainer with Plannotator theme tokens, extended patterns (timelines, SVG diagrams, code blocks, risk tables, Pierre diffs via CDN), and plan/PR-specific guidance. All three servers (Bun, Pi, OpenCode) support the new flag. --- .../references/extended-patterns.md | 629 ++++++++++++++++++ 1 file changed, 629 insertions(+) create mode 100644 apps/skills/plannotator-visual-explainer/references/extended-patterns.md diff --git a/apps/skills/plannotator-visual-explainer/references/extended-patterns.md b/apps/skills/plannotator-visual-explainer/references/extended-patterns.md new file mode 100644 index 000000000..65ecad6fd --- /dev/null +++ b/apps/skills/plannotator-visual-explainer/references/extended-patterns.md @@ -0,0 +1,629 @@ +# Extended Patterns + +Components that complement visual-explainer's toolkit. These use the same Plannotator theme tokens from `theme-override.md` and can be mixed freely with Nico's `.ve-card`, `.kpi-card`, `.pipeline` patterns. + +## Timeline + +Vertical timeline showing phases or sequence — without time estimates. Shows ordering and dependencies, not duration. + +```html +
+
+
Phase 1
+
+
+
+
+
+

Foundation

+

Set up the core infrastructure and initial integrations.

+
+
+ +
+``` + +```css +.timeline { display: flex; flex-direction: column; gap: 0; } + +.timeline-item { + display: grid; + grid-template-columns: 100px 28px 1fr; + gap: 16px; + min-height: 80px; +} + +.timeline-label { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--muted-foreground); + text-align: right; + padding-top: 4px; +} + +.timeline-dot-col { + display: flex; + flex-direction: column; + align-items: center; +} + +.timeline-dot { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--card); + border: 3px solid var(--primary); + flex-shrink: 0; +} + +.timeline-dot.active { background: var(--primary); } + +.timeline-line { + width: 2px; + flex: 1; + background: var(--border); +} + +.timeline-content { padding-bottom: 24px; } + +.timeline-content h4 { + font-family: var(--font-display); + font-size: 1rem; + font-weight: 500; + margin-bottom: 4px; +} + +.timeline-content p { + font-size: 0.88rem; + color: var(--muted-foreground); +} +``` + +The last timeline item should hide the line: `style="background: transparent"` on the `.timeline-line`. + +## Code Blocks with Syntax Highlighting + +Dark-themed code panels showing key interfaces, schemas, or API signatures. Use sparingly — show the 5-10 lines that matter, not full files. + +```html +
+ src/api/handler.ts +
interface Config {
+  port: number;
+  host: string;
+}
+
+``` + +```css +.code-panel { + background: var(--code-bg); + border: 1.5px solid var(--border); + border-radius: var(--radius); + padding: 20px 24px; + overflow-x: auto; + margin: 16px 0; +} + +.code-file { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--muted-foreground); + display: block; + margin-bottom: 8px; +} + +.code-panel pre { + margin: 0; + font-family: var(--font-mono); + font-size: 0.85rem; + line-height: 1.55; + color: var(--foreground); + white-space: pre-wrap; + word-break: break-word; +} + +.code-panel .kw { color: var(--primary); } +.code-panel .fn { color: var(--accent); } +.code-panel .str { color: var(--success); } +.code-panel .cm { color: var(--muted-foreground); font-style: italic; } +.code-panel .num { color: var(--warning); } +``` + +## Risk Table + +Severity-graded risk assessment with colored badges. + +```html +
+
+
Database migration on live table
+
HIGH
+
Run during off-peak with online DDL
+
+
+``` + +```css +.risk-grid { + border: 1.5px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.risk-row { + display: grid; + grid-template-columns: 1fr auto 1.5fr; + gap: 24px; + padding: 16px 24px; + align-items: center; + border-bottom: 1px solid var(--border); +} + +.risk-row:last-child { border-bottom: none; } +.risk-name { font-weight: 500; } +.risk-mitigation { font-size: 0.9rem; color: var(--muted-foreground); } + +.risk-badge { + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.risk-high { + background: color-mix(in oklab, var(--destructive) 15%, transparent); + color: var(--destructive); +} +.risk-med { + background: color-mix(in oklab, var(--warning) 15%, transparent); + color: var(--warning); +} +.risk-low { + background: color-mix(in oklab, var(--success) 15%, transparent); + color: var(--success); +} +``` + +## Open Questions + +Callout cards for unresolved decisions. Each names who can answer. + +```html +
+

Should we use WebSockets or SSE?

+

SSE is simpler but unidirectional. WebSockets add infrastructure complexity.

+ Decide with: infrastructure team +
+``` + +```css +.question { + border-left: 3px solid var(--primary); + padding: 16px 24px; + margin: 16px 0; + background: var(--card); + border-radius: 0 var(--radius) var(--radius) 0; +} + +.question h3 { + font-family: var(--font-display); + font-size: 1.05rem; + font-weight: 500; + margin-bottom: 4px; +} + +.question p { + font-size: 0.9rem; + color: var(--muted-foreground); + line-height: 1.55; +} + +.question-owner { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--primary); + font-weight: 500; + display: block; + margin-top: 8px; +} +``` + +## Inline SVG Diagrams + +For architecture, data flow, and simple flowcharts where Mermaid is overkill (under 8 nodes, simple topology). These produce crisp, theme-aware vector diagrams drawn directly in the HTML. Use Mermaid for anything with complex edge routing (10+ nodes, many crossing connections). + +### Container + +```html +
+ + + + Request flow through the API gateway +
+``` + +```css +.svg-panel { + border: 1.5px solid var(--border); + border-radius: var(--radius); + padding: 24px; + margin: 24px 0; + background: var(--card); +} + +.svg-caption { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--muted-foreground); + display: block; + margin-top: 8px; + text-align: center; +} +``` + +### Arrow markers + +Define in ``. Reference via `marker-end="url(#arrow)"`. + +```svg + + + + + + + + + + + + + + +``` + +### Box node + +```svg + + + API Server + Express + middleware + +``` + +### Highlighted box (new or hot-path component) + +```svg + + + New Service + to be created + +``` + +### Connecting arrows + +```svg + + + + + + + + +``` + +### Edge labels + +```svg +REST +``` + +### Flowchart elements + +```svg + + +Valid? + + + + + + + + + + + + +``` + +### Data flow (request/response) + +```svg + + +POST /api/plan + + + +{ plan, status } +``` + +### Fan-out pattern + +```svg + + + +``` + +### Bar chart + +```svg + + + + + + + + + + + + + + 25 + + + Q1 + +``` + +### Using CSS classes in SVG + +For cleaner markup, define reusable classes inside the SVG: + +```svg + + + +``` + +### Positioning rules + +- `viewBox` with fixed coordinates + `style="width:100%;max-width:720px"` for responsive scaling +- Standard node: `120–160px` wide, `48–56px` tall +- Minimum gap: `60px` horizontal, `40px` vertical +- Arrow label offset: `8–12px` above the line + +### Color roles in SVG + +| Element | Token | +|---------|-------| +| Box background | `var(--card)` | +| Box stroke | `var(--border)` | +| Highlighted box | `var(--primary)` stroke, `color-mix(in oklab, var(--primary) 8%, transparent)` fill | +| Arrows / connectors | `var(--muted-foreground)` | +| Title text | `var(--foreground)` | +| Subtitle / labels | `var(--muted-foreground)` | +| Success path | `var(--success)` | +| Error path | `var(--destructive)` | +| Warning | `var(--warning)` | + +## Section Headers + +Numbered sections with display font headings: + +```css +.section-header { + display: flex; + align-items: baseline; + gap: 14px; + margin-bottom: 24px; + padding-bottom: 8px; + border-bottom: 1.5px solid var(--border); +} + +.section-num { + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 600; + color: var(--primary); +} + +.section-header h2 { + font-family: var(--font-display); + font-size: 1.35rem; + font-weight: 500; +} +``` + +## Tag Chips + +Small inline labels for categorizing items: + +```css +.tag { + font-family: var(--font-mono); + font-size: 0.68rem; + padding: 2px 8px; + border-radius: 4px; + background: var(--muted); + color: var(--muted-foreground); +} + +.tag-highlight { + background: color-mix(in oklab, var(--primary) 12%, transparent); + color: var(--primary); +} +``` + +## Diff Rendering (for PR reviews) + +Use Pierre diffs via CDN for syntax-highlighted, theme-aware diff rendering. Pierre renders into shadow DOM (no style conflicts) and supports Plannotator theme tokens via CSS variable injection. + +```html + + + +``` + +Pierre handles syntax highlighting (via Shiki), line numbers, add/del coloring, word-level diffs, and split/unified views automatically. The `unsafeCSS` option injects Plannotator theme tokens into the shadow DOM. + +For multiple diffs on one page, create one `` per file and set `fileDiff` + `options` on each. + +## Review Comment Bubbles (for PR reviews) + +Speech bubbles with severity-coded left borders. + +```css +.bubble { + position: relative; + background: var(--card); + border: 1.5px solid var(--border); + border-left-width: 4px; + border-radius: 8px; + padding: 12px 14px 12px 16px; + max-width: 680px; +} + +.bubble.blocking { border-left-color: var(--primary); } +.bubble.nit { border-left-color: var(--border); } +.bubble.suggestion { border-left-color: var(--success); } + +.bubble .severity { + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.bubble.blocking .severity { color: var(--primary); } +.bubble.nit .severity { color: var(--muted-foreground); } +.bubble.suggestion .severity { color: var(--success); } +``` + +## File Badges (for PR reviews) + +```css +.file-badge { + font-family: var(--font-mono); + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 6px; + border-radius: 4px; +} + +.file-badge.new { background: color-mix(in oklab, var(--success) 15%, transparent); color: var(--success); } +.file-badge.mod { background: color-mix(in oklab, var(--warning) 15%, transparent); color: var(--warning); } +.file-badge.del { background: color-mix(in oklab, var(--destructive) 15%, transparent); color: var(--destructive); } +``` From e5384b109304932099248763d87168dd3fff6012 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 11 May 2026 21:07:58 -0700 Subject: [PATCH 29/39] feat(ui): copyable hook path + guidance in Settings Hooks tab (#707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): show copyable file path and guidance in Hooks settings tab Always return the improvement hook file path from /api/hooks/status (actual path when present, expected path when absent) so the UI can display it in both states. Add CopyPathButton with tilde-shortened display and full-path clipboard copy. When active, guide users to edit directly or regenerate via /plannotator-compound. When absent, show expected path and both creation options (auto-generate or manual). * fix(ui): anchor displayPath on .plannotator instead of guessing homedir The regex assumed home directories are always two segments deep (/Users/x), which breaks for /root on Linux — it would capture /root/.plannotator as the home prefix and display ~/hooks/... instead of ~/.plannotator/hooks/..., leading users to create the file in the wrong location. --- apps/pi-extension/server/serverPlan.ts | 2 +- packages/server/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index e28e88dc3..af463e5f7 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -39,7 +39,7 @@ import { import { listenOnPort } from "./network.js"; import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; -import { readImprovementHook } from "../generated/improvement-hooks.js"; +import { readImprovementHook, getImprovementHookExpectedPath } from "../generated/improvement-hooks.js"; import { composeImproveContext } from "../generated/pfm-reminder.js"; import { detectProjectName, getRepoInfo } from "./project.js"; import { diff --git a/packages/server/index.ts b/packages/server/index.ts index b6a6501e7..df664bc77 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -45,7 +45,7 @@ import { import { getRepoInfo } from "./repo"; import { detectProjectName } from "./project"; import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "./config"; -import { readImprovementHook } from "@plannotator/shared/improvement-hooks"; +import { readImprovementHook, getImprovementHookExpectedPath } from "@plannotator/shared/improvement-hooks"; import { composeImproveContext } from "@plannotator/shared/pfm-reminder"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, type OpencodeClient } from "./shared-handlers"; import { contentHash, deleteDraft } from "./draft"; From f65e4515aeb3646ed7168e4a9739f5c01ff22282 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 5 May 2026 12:37:12 +0700 Subject: [PATCH 30/39] Preserve truthful approval semantics for Claude plan bypass Thread a clear-context reminder flag through approval decisions, expose a Claude Code-only approval entry that requests bypass mode, and keep the hook response honest by emitting a reminder instead of claiming context was cleared. Constraint: Claude Code PermissionRequest hooks have no documented clearContext response field and bypassPermissions is only a request when the mode is available. Rejected: adding a permission-mode enum or undocumented clearContext field | those would misrepresent hook capabilities and broaden the contract. Confidence: high Scope-risk: moderate Directive: Do not claim Plannotator clears context until Claude Code documents a hook field for that behavior; keep reminder copy truthful. Tested: bun test packages/server apps/hook; bun test packages/editor/wideMode.test.ts packages/ui/hooks/useAgentSettings.test.ts; bun test; bun run build:review; bun run build:hook; bun run typecheck; git diff --check --cached. Not-tested: Browser warning-dialog replay and interactive Claude Code smoke test. Co-authored-by: OmX --- apps/hook/server/index.ts | 4 + packages/ui/components/ApproveDropdown.tsx | 149 ++++++++++++++------- 2 files changed, 108 insertions(+), 45 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 4e3f4e1fa..1bbdb6c7f 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1298,6 +1298,10 @@ if (args[0] === "sessions") { console.log( JSON.stringify({ + ...(result.clearContextNudge && { + systemMessage: + "Plannotator requested bypass mode. Hooks cannot clear context. Run /clear before continuing if you want a fresh implementation session.", + }), hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index ab9c48c7f..b669f76e2 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -2,11 +2,21 @@ import React, { useState, useRef, useEffect } from 'react'; import type { Agent } from '../hooks/useAgents'; import { getAgentSwitchSettings, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch'; +export interface ApproveExtraEntry { + id: string; + label: string; + description?: string; + onSelect: () => void; + disabled?: boolean; +} + interface ApproveDropdownProps { onApprove: () => void; agents: Agent[]; disabled?: boolean; isLoading?: boolean; + extraEntries?: ApproveExtraEntry[]; + showAgentSwitch?: boolean; } function getSelectedLabel(setting: AgentSwitchSettings, agents: Agent[]): string | null { @@ -35,6 +45,8 @@ export const ApproveDropdown: React.FC = ({ agents, disabled = false, isLoading = false, + extraEntries = [], + showAgentSwitch, }) => { const [setting, setSetting] = useState(() => getAgentSwitchSettings()); const [isOpen, setIsOpen] = useState(false); @@ -57,6 +69,10 @@ export const ApproveDropdown: React.FC = ({ }; }, []); + const hasExtraEntries = extraEntries.length > 0; + const shouldShowAgentSwitch = showAgentSwitch ?? agents.length > 0; + const hasDropdownContent = hasExtraEntries || shouldShowAgentSwitch; + const handleSelect = (newSetting: AgentSwitchSettings) => { setSetting(newSetting); saveAgentSwitchSettings(newSetting); @@ -78,16 +94,36 @@ export const ApproveDropdown: React.FC = ({ onApprove(); }; + const handleExtraSelect = (entry: ApproveExtraEntry) => { + if (entry.disabled) return; + setIsOpen(false); + entry.onSelect(); + }; + return (
- {/* Mobile: simple button */} - + {/* Mobile: simple button, with menu when extra actions exist */} +
+ + {hasDropdownContent && ( + + )} +
{/* Desktop: split button */}
@@ -109,8 +145,9 @@ export const ApproveDropdown: React.FC = ({
{/* Dropdown */} - {isOpen && ( -
-
- Switch to agent -
- {agents.map((agent) => { - const selected = isSelected(agent.id, setting); - return ( + {isOpen && hasDropdownContent && ( +
+ {hasExtraEntries && ( + <> + {extraEntries.map((entry) => ( + + ))} + {shouldShowAgentSwitch &&
} + + )} + {shouldShowAgentSwitch && ( + <> +
+ Switch to agent +
+ {agents.map((agent) => { + const selected = isSelected(agent.id, setting); + return ( + + ); + })} + {isCustom && setting.customName && ( + + )} +
- ); - })} - {isCustom && setting.customName && ( - + )} -
-
)}
From 4a27c9badad48b7d4f359ea5fb0ebdf09355e101 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 5 May 2026 15:05:47 +0700 Subject: [PATCH 31/39] Keep approval payloads truthful across Claude Code and Pi Constraint: Claude Code hooks cannot clear context directly; the new action remains a truthful reminder plus bypass-mode approval. Rejected: Sending OpenCode agent-switch state for Claude Code | it leaked build (?) UI state and misleading payload fields. Confidence: high Scope-risk: narrow Directive: Keep OpenCode agent switching gated to opencode-origin approval payloads. Tested: bun run typecheck; bun test; bun run build:review; bun run build:hook Not-tested: Manual browser click-through of the Claude Code dropdown. --- apps/pi-extension/plannotator-browser.ts | 1 + apps/pi-extension/plannotator-events.ts | 2 + apps/pi-extension/server/serverPlan.ts | 4 ++ packages/editor/App.tsx | 9 +--- packages/editor/approvalBody.test.ts | 33 +++++++++++++ packages/editor/approvalBody.ts | 47 +++++++++++++++++++ .../ui/components/ApproveDropdown.test.tsx | 24 ++++++++++ packages/ui/components/ApproveDropdown.tsx | 8 ++-- 8 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 packages/editor/approvalBody.test.ts create mode 100644 packages/editor/approvalBody.ts create mode 100644 packages/ui/components/ApproveDropdown.test.tsx diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index 2235bca6f..a47e89a8f 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -35,6 +35,7 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface BrowserDecisionSession { diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index 12845ae70..f2754a6de 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -73,6 +73,7 @@ export interface PlannotatorReviewResultEvent { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlannotatorReviewStatusPayload { @@ -248,6 +249,7 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { savedPath: result.savedPath, agentSwitch: result.agentSwitch, permissionMode: result.permissionMode, + clearContextNudge: result.clearContextNudge, } satisfies PlannotatorReviewResultEvent; setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult }); pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index af463e5f7..a894b21d2 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -62,6 +62,7 @@ export interface PlanReviewDecision { savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlanServerResult { @@ -375,6 +376,7 @@ export async function startPlanReviewServer(options: { let feedback: string | undefined; let agentSwitch: string | undefined; let requestedPermissionMode: string | undefined; + let clearContextNudge: boolean | undefined; let planSaveEnabled = true; let planSaveCustomPath: string | undefined; try { @@ -383,6 +385,7 @@ export async function startPlanReviewServer(options: { if (body.agentSwitch) agentSwitch = body.agentSwitch as string; if (body.permissionMode) requestedPermissionMode = body.permissionMode as string; + if (body.clearContextNudge === true) clearContextNudge = true; if (body.planSave !== undefined) { const ps = body.planSave as { enabled: boolean; customPath?: string }; planSaveEnabled = ps.enabled; @@ -444,6 +447,7 @@ export async function startPlanReviewServer(options: { savedPath, agentSwitch, permissionMode: effectivePermissionMode, + clearContextNudge, }); json(res, { ok: true, savedPath }); } else if ( diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 25d64af52..9c19abdba 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,6 +78,7 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; +import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -962,14 +963,6 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - // Build request body - include integrations if enabled - const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {}; - - // Include permission mode for Claude Code - if (origin === 'claude-code') { - body.permissionMode = permissionMode; - } - const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts new file mode 100644 index 000000000..c1f024e74 --- /dev/null +++ b/packages/editor/approvalBody.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test'; +import { buildApprovalRequestBody } from './approvalBody'; + +describe('buildApprovalRequestBody', () => { + test('omits agentSwitch for Claude Code approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + override: { + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('keeps agentSwitch for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); +}); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts new file mode 100644 index 000000000..7ebb4b834 --- /dev/null +++ b/packages/editor/approvalBody.ts @@ -0,0 +1,47 @@ +import type { Origin } from '@plannotator/shared/agents'; +import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; + +export type ApprovalOverride = { + permissionMode?: PermissionMode; + clearContextNudge?: boolean; +}; + +export interface ApprovalRequestBody { + obsidian?: object; + bear?: object; + octarine?: object; + feedback?: string; + agentSwitch?: string; + planSave?: { enabled: boolean; customPath?: string }; + permissionMode?: string; + clearContextNudge?: boolean; +} + +export function buildApprovalRequestBody(options: { + origin: Origin | null; + permissionMode: PermissionMode; + override?: ApprovalOverride; + effectiveAgent?: string; + planSaveSettings: { enabled: boolean; customPath?: string | null }; +}): ApprovalRequestBody { + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; + const body: ApprovalRequestBody = {}; + + if (origin === 'claude-code') { + body.permissionMode = override.permissionMode ?? permissionMode; + if (override.clearContextNudge) { + body.clearContextNudge = true; + } + } + + if (origin === 'opencode' && effectiveAgent) { + body.agentSwitch = effectiveAgent; + } + + body.planSave = { + enabled: planSaveSettings.enabled, + ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), + }; + + return body; +} diff --git a/packages/ui/components/ApproveDropdown.test.tsx b/packages/ui/components/ApproveDropdown.test.tsx new file mode 100644 index 000000000..967f8e4be --- /dev/null +++ b/packages/ui/components/ApproveDropdown.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { describe, expect, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ApproveDropdown } from './ApproveDropdown'; + +describe('ApproveDropdown', () => { + test('does not show agent-switch label when only extra approval entries are enabled', () => { + const html = renderToStaticMarkup( + {}} + agents={[]} + extraEntries={[{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + onSelect: () => {}, + }]} + />, + ); + + expect(html).toContain('Approve'); + expect(html).not.toContain('build'); + expect(html).not.toContain('(?)'); + }); +}); diff --git a/packages/ui/components/ApproveDropdown.tsx b/packages/ui/components/ApproveDropdown.tsx index b669f76e2..9424696d3 100644 --- a/packages/ui/components/ApproveDropdown.tsx +++ b/packages/ui/components/ApproveDropdown.tsx @@ -79,10 +79,10 @@ export const ApproveDropdown: React.FC = ({ setIsOpen(false); }; - const agentLabel = getSelectedLabel(setting, agents); - const isNoSwitch = setting.switchTo === 'disabled'; - const isCustom = setting.switchTo === 'custom'; - const notFound = agentLabel && !isNoSwitch && !isCustom + const agentLabel = shouldShowAgentSwitch ? getSelectedLabel(setting, agents) : null; + const isNoSwitch = shouldShowAgentSwitch && setting.switchTo === 'disabled'; + const isCustom = shouldShowAgentSwitch && setting.switchTo === 'custom'; + const notFound = shouldShowAgentSwitch && agentLabel && !isNoSwitch && !isCustom && !agents.some(a => a.id.toLowerCase() === setting.switchTo.toLowerCase()); const baseClasses = disabled From 8647968913f7114dfaca1f00e9a570f1e2e5bfd8 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 7 May 2026 02:20:25 +0700 Subject: [PATCH 32/39] omx(team): checkpoint worker-1 shutdown changes --- packages/editor/App.tsx | 50 ++++++++++ packages/editor/approvalBody.test.ts | 113 +++++++++++++++++++++++ packages/editor/approvalBody.ts | 16 +++- packages/editor/components/AppHeader.tsx | 10 +- packages/ui/components/Settings.tsx | 4 +- packages/ui/utils/permissionMode.ts | 16 +++- 6 files changed, 199 insertions(+), 10 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 9c19abdba..2b346bcfb 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -963,6 +963,19 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; + const shouldUseNativeClear = + origin === 'claude-code' && + pendingToolName === 'ExitPlanMode' && + (override.deferToNativeForClear || (override.permissionMode ?? permissionMode) === 'bypassPermissionsClearReminder'); + if (shouldUseNativeClear) { + try { + const response = await fetch('/api/enable-clear-context', { method: 'POST' }); + if (response.ok) setShowClearContextBanner(false); + } catch { + setShowClearContextBanner(true); + } + } + const effectiveAgent = getEffectiveAgentName(getAgentSwitchSettings()); const body = buildApprovalRequestBody({ origin, @@ -970,6 +983,7 @@ const App: React.FC = () => { override, effectiveAgent, planSaveSettings, + toolName: pendingToolName, }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); @@ -1041,6 +1055,42 @@ const App: React.FC = () => { } }; +<<<<<<< HEAD +======= + const approveWithClaudeCodeWarning = useCallback((override: ApprovalOverride = {}) => { + setPendingApprovalOverride(override); + if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { + setShowClaudeCodeWarning(true); + return; + } + handleApprove(override); + }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); + + const claudeCodeExtraEntries = useMemo(() => { + if (origin !== 'claude-code') return []; + if (pendingToolName === 'ExitPlanMode') { + return [{ + id: 'approve-bypass-native-clear', + label: 'Approve + Bypass + Clear Context (native)', + description: "Defers to Claude Code's native plan-accept dialog so it can clear context and set bypass permissions.", + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }), + }]; + } + return [{ + id: 'approve-bypass-clear-reminder', + label: 'Approve + Bypass + /clear Reminder', + description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly outside plan acceptance.', + onSelect: () => approveWithClaudeCodeWarning({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }), + }]; + }, [approveWithClaudeCodeWarning, origin, pendingToolName]); + +>>>>>>> ff79946 (Keep Plannotator approvals actionable for bypass clear context) // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); diff --git a/packages/editor/approvalBody.test.ts b/packages/editor/approvalBody.test.ts index c1f024e74..3d1977074 100644 --- a/packages/editor/approvalBody.test.ts +++ b/packages/editor/approvalBody.test.ts @@ -2,6 +2,32 @@ import { describe, expect, test } from 'bun:test'; import { buildApprovalRequestBody } from './approvalBody'; describe('buildApprovalRequestBody', () => { + test('maps bypass clear reminder mode to native Claude Code clear on ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'ExitPlanMode', + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + planSave: { enabled: true }, + }); + }); + + test('maps bypass clear reminder mode to reminder fallback outside ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'OtherTool', + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + test('omits agentSwitch for Claude Code approvals', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', @@ -19,6 +45,37 @@ describe('buildApprovalRequestBody', () => { }); }); + test('keeps bypass clear reminder override fallback fields for Claude Code approvals without ExitPlanMode', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + override: { + permissionMode: 'bypassPermissionsClearReminder', + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + clearContextNudge: true, + planSave: { enabled: true }, + }); + }); + + test('uses native clear for bypass clear reminder override when ExitPlanMode is known', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + toolName: 'ExitPlanMode', + override: { + permissionMode: 'bypassPermissionsClearReminder', + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + planSave: { enabled: true }, + }); + }); + test('keeps agentSwitch for OpenCode approvals', () => { expect(buildApprovalRequestBody({ origin: 'opencode', @@ -30,4 +87,60 @@ describe('buildApprovalRequestBody', () => { planSave: { enabled: true }, }); }); + + test('ignores bypass clear reminder mode for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'bypassPermissionsClearReminder', + effectiveAgent: 'build', + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); + + test('forwards deferToNativeForClear for Claude Code bypass approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + override: { + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + planSave: { enabled: true }, + }); + }); + + test('does not forward deferToNativeForClear for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'acceptEdits', + effectiveAgent: 'build', + override: { + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); + + test('does not forward deferToNativeForClear for Gemini origin', () => { + expect(buildApprovalRequestBody({ + origin: 'gemini-cli', + permissionMode: 'acceptEdits', + override: { + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + planSave: { enabled: true }, + }); + }); }); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index 7ebb4b834..a5e1ba7b4 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -4,6 +4,7 @@ import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; export type ApprovalOverride = { permissionMode?: PermissionMode; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }; export interface ApprovalRequestBody { @@ -15,6 +16,7 @@ export interface ApprovalRequestBody { planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string; clearContextNudge?: boolean; + deferToNativeForClear?: boolean; } export function buildApprovalRequestBody(options: { @@ -23,13 +25,21 @@ export function buildApprovalRequestBody(options: { override?: ApprovalOverride; effectiveAgent?: string; planSaveSettings: { enabled: boolean; customPath?: string | null }; + toolName?: string; }): ApprovalRequestBody { - const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings } = options; + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings, toolName } = options; const body: ApprovalRequestBody = {}; if (origin === 'claude-code') { - body.permissionMode = override.permissionMode ?? permissionMode; - if (override.clearContextNudge) { + const effectivePermissionMode = override.permissionMode ?? permissionMode; + const wantsClearContext = effectivePermissionMode === 'bypassPermissionsClearReminder'; + const useNativeClear = override.deferToNativeForClear || (wantsClearContext && toolName === 'ExitPlanMode'); + + body.permissionMode = wantsClearContext ? 'bypassPermissions' : effectivePermissionMode; + + if (useNativeClear) { + body.deferToNativeForClear = true; + } else if (override.clearContextNudge || wantsClearContext) { body.clearContextNudge = true; } } diff --git a/packages/editor/components/AppHeader.tsx b/packages/editor/components/AppHeader.tsx index ad3ca9b74..8c44d7f1b 100644 --- a/packages/editor/components/AppHeader.tsx +++ b/packages/editor/components/AppHeader.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type { Origin } from '@plannotator/shared/agents'; import type { Agent } from '@plannotator/ui/hooks/useAgents'; import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons'; -import { ApproveDropdown } from '@plannotator/ui/components/ApproveDropdown'; +import { ApproveDropdown, type ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; import { Settings } from '@plannotator/ui/components/Settings'; import { PlanHeaderMenu } from '@plannotator/ui/components/PlanHeaderMenu'; import type { CallbackConfig } from '@plannotator/ui/utils/callback'; @@ -27,6 +27,7 @@ interface AppHeaderProps { canShareCurrentSession: boolean; agentName: string; availableAgents: Agent[]; + approveExtraEntries?: ApproveExtraEntry[]; showAnnotationsWarning: boolean; // Callback config (null when no bot callback) @@ -87,6 +88,7 @@ export const AppHeader = React.memo(({ canShareCurrentSession, agentName, availableAgents, + approveExtraEntries = [], showAnnotationsWarning, callbackConfig, taterMode, @@ -198,10 +200,12 @@ export const AppHeader = React.memo(({ )} {(!annotateMode || gate) && ( - origin === 'opencode' && !annotateMode && availableAgents.length > 0 ? ( + !annotateMode && ((origin === 'opencode' && availableAgents.length > 0) || approveExtraEntries.length > 0) ? ( 0} disabled={isSubmitting} isLoading={isSubmitting} /> diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index e2982315b..48a4bdb6f 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -80,6 +80,7 @@ interface SettingsProps { origin?: Origin | null; mode?: 'plan' | 'review'; onUIPreferencesChange?: (prefs: UIPreferences) => void; + onPermissionModeChange?: (mode: PermissionMode) => void; /** Externally controlled open state (for mobile menu integration) */ externalOpen?: boolean; onExternalClose?: () => void; @@ -599,7 +600,7 @@ const CommentsTab: React.FC = () => { ); }; -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, onPermissionModeChange, externalOpen, onExternalClose, aiProviders = [], gitUser }) => { const [showDialog, setShowDialog] = useState(false); const [themePreview, setThemePreview] = useState(false); @@ -803,6 +804,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const handlePermissionModeChange = (mode: PermissionMode) => { setPermissionMode(mode); savePermissionModeSettings(mode); + onPermissionModeChange?.(mode); }; const handleDefaultNotesAppChange = (app: DefaultNotesApp) => { diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index 809995377..ed72dc4f1 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,6 +6,7 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls + * - bypassPermissionsClearReminder: Persisted UI mode that uses native clear-on-accept for Claude Code plan approvals and a /clear reminder fallback otherwise * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -15,7 +16,7 @@ import { storage } from './storage'; const STORAGE_KEY_MODE = 'plannotator-permission-mode'; const STORAGE_KEY_CONFIGURED = 'plannotator-permission-mode-configured'; -export type PermissionMode = 'bypassPermissions' | 'acceptEdits' | 'default'; +export type PermissionMode = 'bypassPermissions' | 'bypassPermissionsClearReminder' | 'acceptEdits' | 'default'; export interface PermissionModeSettings { mode: PermissionMode; @@ -33,6 +34,11 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de label: 'Bypass Permissions', description: 'Auto-approve all tool calls (equivalent to --dangerously-skip-permissions)', }, + { + value: 'bypassPermissionsClearReminder', + label: 'Bypass + Clear Context', + description: 'For Claude Code plan approvals, defer to the native clear-context flow and bypass permissions; otherwise emit a /clear reminder.', + }, { value: 'default', label: 'Manual Approval', @@ -42,15 +48,19 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de const DEFAULT_MODE: PermissionMode = 'acceptEdits'; +function isPermissionMode(value: string | null): value is PermissionMode { + return PERMISSION_MODE_OPTIONS.some((option) => option.value === value); +} + /** * Get current permission mode settings from storage */ export function getPermissionModeSettings(): PermissionModeSettings { - const mode = storage.getItem(STORAGE_KEY_MODE) as PermissionMode | null; + const mode = storage.getItem(STORAGE_KEY_MODE); const configured = storage.getItem(STORAGE_KEY_CONFIGURED) === 'true'; return { - mode: mode || DEFAULT_MODE, + mode: isPermissionMode(mode) ? mode : DEFAULT_MODE, configured, }; } From 59f089e1d779527b5983c125ec9db31a28c35767 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 15:58:05 +0700 Subject: [PATCH 33/39] task: Resolve UI/editor conflict surfaces --- packages/editor/App.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 2b346bcfb..bd36875ba 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -79,6 +79,7 @@ import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; +import type { ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -952,7 +953,7 @@ const App: React.FC = () => { }; // API mode handlers - const handleApprove = async () => { + const handleApprove = async (override: ApprovalOverride = {}) => { setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); @@ -1055,8 +1056,6 @@ const App: React.FC = () => { } }; -<<<<<<< HEAD -======= const approveWithClaudeCodeWarning = useCallback((override: ApprovalOverride = {}) => { setPendingApprovalOverride(override); if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { @@ -1090,7 +1089,6 @@ const App: React.FC = () => { }]; }, [approveWithClaudeCodeWarning, origin, pendingToolName]); ->>>>>>> ff79946 (Keep Plannotator approvals actionable for bypass clear context) // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); @@ -2243,7 +2241,9 @@ const App: React.FC = () => { onClose={() => setShowClaudeCodeWarning(false)} onConfirm={() => { setShowClaudeCodeWarning(false); - handleApprove(); + const override = pendingApprovalOverride; + setPendingApprovalOverride({}); + handleApprove(override); }} title="Annotations Won't Be Sent" message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.} From 718705794c0f6d162907eb43e9db3e4f92ce50f4 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Thu, 14 May 2026 23:06:06 +0700 Subject: [PATCH 34/39] fix(hook): fail closed for clear-context approvals --- apps/hook/server/hookDecision.test.ts | 56 +++++++++ apps/hook/server/hookDecision.ts | 110 ++++++++++++++++++ apps/hook/server/index.ts | 70 +++-------- .../editor/App.clearContextBanner.test.ts | 21 ++++ packages/editor/App.tsx | 82 ++----------- packages/editor/approvalBody.test.ts | 88 ++++++++++++-- packages/editor/approvalBody.ts | 15 ++- packages/ui/utils/permissionMode.ts | 4 +- 8 files changed, 311 insertions(+), 135 deletions(-) create mode 100644 apps/hook/server/hookDecision.test.ts create mode 100644 apps/hook/server/hookDecision.ts create mode 100644 packages/editor/App.clearContextBanner.test.ts diff --git a/apps/hook/server/hookDecision.test.ts b/apps/hook/server/hookDecision.test.ts new file mode 100644 index 000000000..63e678c76 --- /dev/null +++ b/apps/hook/server/hookDecision.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { formatClaudePlanHookOutput } from "./hookDecision"; + +describe("formatClaudePlanHookOutput", () => { + test("native handoff emits PreToolUse ask only when native clear was enabled", () => { + expect(formatClaudePlanHookOutput({ + result: { approved: true, permissionMode: "bypassPermissions", deferToNativeForClear: true }, + hookEventName: "PreToolUse", + toolName: "ExitPlanMode", + detectedOrigin: "claude-code", + nativeClearEnabled: true, + })).toEqual({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + }, + }); + }); + + test("PermissionRequest native defer falls back to explicit allow JSON", () => { + const output = formatClaudePlanHookOutput({ + result: { approved: true, deferToNativeForClear: true }, + hookEventName: "PermissionRequest", + toolName: "ExitPlanMode", + detectedOrigin: "claude-code", + nativeClearEnabled: true, + }) as any; + + expect(output.hookSpecificOutput.hookEventName).toBe("PermissionRequest"); + expect(output.hookSpecificOutput.decision.behavior).toBe("allow"); + expect(output.hookSpecificOutput.decision.updatedPermissions).toEqual([ + { type: "setMode", mode: "bypassPermissions", destination: "session" }, + ]); + expect(output.systemMessage).toContain("/clear"); + }); + + test("normal PermissionRequest approval includes updatedPermissions", () => { + expect(formatClaudePlanHookOutput({ + result: { approved: true, permissionMode: "bypassPermissions", clearContextNudge: true }, + hookEventName: "PermissionRequest", + toolName: "ExitPlanMode", + detectedOrigin: "claude-code", + })).toEqual({ + systemMessage: expect.stringContaining("/clear"), + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "allow", + updatedPermissions: [ + { type: "setMode", mode: "bypassPermissions", destination: "session" }, + ], + }, + }, + }); + }); +}); diff --git a/apps/hook/server/hookDecision.ts b/apps/hook/server/hookDecision.ts new file mode 100644 index 000000000..1f613f1cf --- /dev/null +++ b/apps/hook/server/hookDecision.ts @@ -0,0 +1,110 @@ +import { + buildPlanFileRule, + getPlanDeniedPrompt, + getPlanToolName, +} from "@plannotator/shared/prompts"; +import type { Origin } from "@plannotator/shared/agents"; + +export type PlanDecisionResult = { + approved: boolean; + feedback?: string; + permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; +}; + +export type ClaudeHookEventName = "PreToolUse" | "PermissionRequest"; + +export function normalizeClaudeHookEventName(value: unknown): ClaudeHookEventName { + return value === "PreToolUse" ? "PreToolUse" : "PermissionRequest"; +} + +function clearContextSystemMessage(): string { + return "Plannotator requested bypass mode. Hooks cannot clear context. Run /clear before continuing if you want a fresh implementation session."; +} + +export function formatClaudePlanHookOutput(options: { + result: PlanDecisionResult; + hookEventName: ClaudeHookEventName; + toolName: string; + detectedOrigin: Origin; + nativeClearEnabled?: boolean; + planFilename?: string; +}): Record { + const { result, hookEventName, toolName, detectedOrigin, nativeClearEnabled, planFilename } = options; + const isExitPlanMode = toolName === "ExitPlanMode"; + + if (result.approved && result.deferToNativeForClear && isExitPlanMode) { + if (hookEventName === "PreToolUse" && nativeClearEnabled) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "ask", + }, + }; + } + + result.clearContextNudge = true; + result.permissionMode ||= "bypassPermissions"; + } + + if (hookEventName === "PreToolUse") { + if (result.approved) { + return { + ...(result.clearContextNudge && { systemMessage: clearContextSystemMessage() }), + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + }, + }; + } + + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: getPlanDeniedPrompt(detectedOrigin, undefined, { + toolName: getPlanToolName(detectedOrigin), + planFileRule: buildPlanFileRule(getPlanToolName(detectedOrigin), planFilename), + feedback: result.feedback || "Plan changes requested", + }), + }, + }; + } + + if (result.approved) { + const updatedPermissions = []; + if (result.permissionMode) { + updatedPermissions.push({ + type: "setMode", + mode: result.permissionMode, + destination: "session", + }); + } + + return { + ...(result.clearContextNudge && { systemMessage: clearContextSystemMessage() }), + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "allow", + ...(updatedPermissions.length > 0 && { updatedPermissions }), + }, + }, + }; + } + + return { + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "deny", + message: getPlanDeniedPrompt(detectedOrigin, undefined, { + toolName: getPlanToolName(detectedOrigin), + planFileRule: "", + feedback: result.feedback || "Plan changes requested", + }), + }, + }, + }; +} diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 1bbdb6c7f..83bc711c5 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -111,6 +111,7 @@ import { isVersionInvocation, } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; +import { formatClaudePlanHookOutput, normalizeClaudeHookEventName } from "./hookDecision"; import path from "path"; import { tmpdir } from "os"; @@ -1272,62 +1273,27 @@ if (args[0] === "sessions") { ); } } else { - // Claude Code: PermissionRequest hook decision - if ( + const hookEventName = normalizeClaudeHookEventName(event.hook_event_name); + const nativeClearEnabled = result.approved && result.deferToNativeForClear && + hookEventName === "PreToolUse" && toolName === "ExitPlanMode" - ) { - const nativeClearEnabled = await ensureClearContextSettingEnabled(); - if (nativeClearEnabled) { - process.exit(0); - } - result.clearContextNudge = true; - result.permissionMode ||= "bypassPermissions"; - } - - if (result.approved) { - const updatedPermissions = []; - if (result.permissionMode) { - updatedPermissions.push({ - type: "setMode", - mode: result.permissionMode, - destination: "session", - }); - } - - console.log( - JSON.stringify({ - ...(result.clearContextNudge && { - systemMessage: - "Plannotator requested bypass mode. Hooks cannot clear context. Run /clear before continuing if you want a fresh implementation session.", - }), - hookSpecificOutput: { - hookEventName: "PermissionRequest", - decision: { - behavior: "allow", - ...(updatedPermissions.length > 0 && { updatedPermissions }), - }, - }, - }) - ); - } else { - console.log( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PermissionRequest", - decision: { - behavior: "deny", - message: getPlanDeniedPrompt(detectedOrigin, undefined, { - toolName: getPlanToolName(detectedOrigin), - planFileRule: "", - feedback: result.feedback || "Plan changes requested", - }), - }, - }, + ? await ensureClearContextSettingEnabled() + : false; + + console.log( + JSON.stringify( + formatClaudePlanHookOutput({ + result, + hookEventName, + toolName, + detectedOrigin, + nativeClearEnabled, + planFilename, }) - ); - } + ) + ); } process.exit(0); diff --git a/packages/editor/App.clearContextBanner.test.ts b/packages/editor/App.clearContextBanner.test.ts new file mode 100644 index 000000000..443eb1776 --- /dev/null +++ b/packages/editor/App.clearContextBanner.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +describe('App clear-context approval UI', () => { + test('does not render the blocking native clear-on-accept prompt', () => { + const source = readFileSync(join(import.meta.dir, 'App.tsx'), 'utf8'); + + expect(source).not.toContain('showClearContextBanner'); + expect(source).not.toContain('Enable native clear-on-accept?'); + expect(source).not.toContain('aria-label="Enable native clear-on-accept"'); + }); + + test('gates the native clear setup API behind the shared native-clear predicate', () => { + const source = readFileSync(join(import.meta.dir, 'App.tsx'), 'utf8'); + + expect(source.match(/\/api\/enable-clear-context/g)?.length).toBe(1); + expect(source).toContain('shouldEnableNativeClearBeforeApprove({ origin, permissionMode, toolName: pendingToolName, override })'); + expect(source).toContain("clearContextNudge: true"); + }); +}); diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index bd36875ba..5cf5cb906 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -78,8 +78,7 @@ import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiff import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; -import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; -import type { ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; +import { buildApprovalRequestBody, shouldEnableNativeClearBeforeApprove, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -154,7 +153,6 @@ const App: React.FC = () => { const [isApiMode, setIsApiMode] = useState(false); const [origin, setOrigin] = useState(null); const [pendingToolName, setPendingToolName] = useState(); - const [showClearContextBanner, setShowClearContextBanner] = useState(false); const [pendingApprovalOverride, setPendingApprovalOverride] = useState({}); const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); @@ -964,16 +962,19 @@ const App: React.FC = () => { ? await autoSavePromiseRef.current : autoSaveResultsRef.current; - const shouldUseNativeClear = - origin === 'claude-code' && - pendingToolName === 'ExitPlanMode' && - (override.deferToNativeForClear || (override.permissionMode ?? permissionMode) === 'bypassPermissionsClearReminder'); - if (shouldUseNativeClear) { + let approvalOverride = override; + if (shouldEnableNativeClearBeforeApprove({ origin, permissionMode, toolName: pendingToolName, override })) { try { const response = await fetch('/api/enable-clear-context', { method: 'POST' }); - if (response.ok) setShowClearContextBanner(false); + if (!response.ok) { + throw new Error(`Unable to enable native clear-context: ${response.status}`); + } } catch { - setShowClearContextBanner(true); + approvalOverride = { + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }; + toast.warning('Native clear-on-accept unavailable; approving with a /clear reminder instead.'); } } @@ -981,7 +982,7 @@ const App: React.FC = () => { const body = buildApprovalRequestBody({ origin, permissionMode, - override, + override: approvalOverride, effectiveAgent, planSaveSettings, toolName: pendingToolName, @@ -2361,65 +2362,6 @@ const App: React.FC = () => { {/* Update notification */} - {showClearContextBanner && ( -
-
- Enable native clear-on-accept? -
-
- Plannotator will write{' '} - showClearContextOnPlanAccept: true to your Claude - Code settings so Claude Code can clear planning context through - its native approval flow. -
-
- - -
-
- )} {/* Image Annotator for pasted images */} { + test('enables native clear for explicit Claude Code ExitPlanMode overrides', () => { + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + toolName: 'ExitPlanMode', + override: { deferToNativeForClear: true }, + })).toBe(true); + }); + + test('does not enable native clear for saved bypass clear mode', () => { + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'ExitPlanMode', + })).toBe(false); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + permissionMode: 'acceptEdits', + toolName: 'ExitPlanMode', + override: { permissionMode: 'bypassPermissionsClearReminder' }, + })).toBe(false); + }); + + test('does not enable native clear outside Claude Code ExitPlanMode', () => { + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'claude-code', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'OtherTool', + })).toBe(false); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'opencode', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'ExitPlanMode', + override: { deferToNativeForClear: true }, + })).toBe(false); + + expect(shouldEnableNativeClearBeforeApprove({ + origin: 'gemini-cli', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'ExitPlanMode', + override: { deferToNativeForClear: true }, + })).toBe(false); + }); +}); describe('buildApprovalRequestBody', () => { - test('maps bypass clear reminder mode to native Claude Code clear on ExitPlanMode', () => { + test('maps saved bypass clear reminder mode to hook approval with clear reminder on Claude Code ExitPlanMode', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'bypassPermissionsClearReminder', @@ -10,7 +58,7 @@ describe('buildApprovalRequestBody', () => { planSaveSettings: { enabled: true }, })).toEqual({ permissionMode: 'bypassPermissions', - deferToNativeForClear: true, + clearContextNudge: true, planSave: { enabled: true }, }); }); @@ -60,7 +108,7 @@ describe('buildApprovalRequestBody', () => { }); }); - test('uses native clear for bypass clear reminder override when ExitPlanMode is known', () => { + test('uses reminder fallback for bypass clear reminder override when ExitPlanMode is known', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'acceptEdits', @@ -71,7 +119,7 @@ describe('buildApprovalRequestBody', () => { planSaveSettings: { enabled: true }, })).toEqual({ permissionMode: 'bypassPermissions', - deferToNativeForClear: true, + clearContextNudge: true, planSave: { enabled: true }, }); }); @@ -93,6 +141,7 @@ describe('buildApprovalRequestBody', () => { origin: 'opencode', permissionMode: 'bypassPermissionsClearReminder', effectiveAgent: 'build', + toolName: 'ExitPlanMode', planSaveSettings: { enabled: true }, })).toEqual({ agentSwitch: 'build', @@ -100,10 +149,11 @@ describe('buildApprovalRequestBody', () => { }); }); - test('forwards deferToNativeForClear for Claude Code bypass approvals', () => { + test('forwards deferToNativeForClear for explicit Claude Code ExitPlanMode bypass approvals', () => { expect(buildApprovalRequestBody({ origin: 'claude-code', permissionMode: 'acceptEdits', + toolName: 'ExitPlanMode', override: { permissionMode: 'bypassPermissions', deferToNativeForClear: true, @@ -116,11 +166,28 @@ describe('buildApprovalRequestBody', () => { }); }); - test('does not forward deferToNativeForClear for OpenCode approvals', () => { + test('does not forward deferToNativeForClear without ExitPlanMode', () => { expect(buildApprovalRequestBody({ - origin: 'opencode', + origin: 'claude-code', permissionMode: 'acceptEdits', + toolName: 'OtherTool', + override: { + permissionMode: 'bypassPermissions', + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + permissionMode: 'bypassPermissions', + planSave: { enabled: true }, + }); + }); + + test('does not forward deferToNativeForClear or native clear for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'bypassPermissionsClearReminder', effectiveAgent: 'build', + toolName: 'ExitPlanMode', override: { deferToNativeForClear: true, }, @@ -131,10 +198,11 @@ describe('buildApprovalRequestBody', () => { }); }); - test('does not forward deferToNativeForClear for Gemini origin', () => { + test('does not forward deferToNativeForClear or native clear for Gemini origin', () => { expect(buildApprovalRequestBody({ origin: 'gemini-cli', - permissionMode: 'acceptEdits', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'ExitPlanMode', override: { deferToNativeForClear: true, }, diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts index a5e1ba7b4..ed4737930 100644 --- a/packages/editor/approvalBody.ts +++ b/packages/editor/approvalBody.ts @@ -19,6 +19,19 @@ export interface ApprovalRequestBody { deferToNativeForClear?: boolean; } +export function shouldEnableNativeClearBeforeApprove(options: { + origin: Origin | null; + permissionMode: PermissionMode; + toolName?: string; + override?: ApprovalOverride; +}): boolean { + return ( + options.origin === 'claude-code' && + options.toolName === 'ExitPlanMode' && + options.override?.deferToNativeForClear === true + ); +} + export function buildApprovalRequestBody(options: { origin: Origin | null; permissionMode: PermissionMode; @@ -33,7 +46,7 @@ export function buildApprovalRequestBody(options: { if (origin === 'claude-code') { const effectivePermissionMode = override.permissionMode ?? permissionMode; const wantsClearContext = effectivePermissionMode === 'bypassPermissionsClearReminder'; - const useNativeClear = override.deferToNativeForClear || (wantsClearContext && toolName === 'ExitPlanMode'); + const useNativeClear = shouldEnableNativeClearBeforeApprove({ origin, permissionMode, toolName, override }); body.permissionMode = wantsClearContext ? 'bypassPermissions' : effectivePermissionMode; diff --git a/packages/ui/utils/permissionMode.ts b/packages/ui/utils/permissionMode.ts index ed72dc4f1..f35711b9f 100644 --- a/packages/ui/utils/permissionMode.ts +++ b/packages/ui/utils/permissionMode.ts @@ -6,7 +6,7 @@ * * Available modes: * - bypassPermissions: Auto-approve all tool calls - * - bypassPermissionsClearReminder: Persisted UI mode that uses native clear-on-accept for Claude Code plan approvals and a /clear reminder fallback otherwise + * - bypassPermissionsClearReminder: Persisted UI mode that requests bypassPermissions and emits a /clear reminder * - acceptEdits: Auto-approve file edits only * - default: Manually approve each tool call */ @@ -37,7 +37,7 @@ export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string; de { value: 'bypassPermissionsClearReminder', label: 'Bypass + Clear Context', - description: 'For Claude Code plan approvals, defer to the native clear-context flow and bypass permissions; otherwise emit a /clear reminder.', + description: 'Approve with bypass permissions and show a /clear reminder. Use the explicit native option when available for Claude Code’s own clear-context prompt.', }, { value: 'default', From 92b9c2a8cd00a071a0ff006c392a811560df9d2e Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:03:38 +0700 Subject: [PATCH 35/39] fix(hook): replace dead deferNative/behavior:defer with exit-0 bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit behavior:"defer" is not a valid member of PermissionRequestHookSpecificOutput .decision — it belongs on HookPermissionDecision (PreToolUse permissionDecision field). Emitting it caused CC to receive an undeclared field and fall through to plain approve. Fix: merge the deferNative branch into the deferToNativeForClear exit-0 path. Both the one-time override (deferToNativeForClear:true) and the persistent setting (permissionMode:"deferNative") now call ensureClearContextSettingEnabled, spawn the keystroke injector if PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR=1, then process.exit(0) — emitting nothing so CC shows its own native plan dialog. Also extend App.tsx shouldUseNativeClear to fire the consent pre-flight call (/api/enable-clear-context) for persistent deferNative mode, not just the one-time deferToNativeForClear override. Gate test confirmed (2026-06-09): exit-0 + no output → CC native numbered dialog appears with "Yes, clear context and bypass permissions" as option 1. keystrokeInjector sends "1\n" to auto-select it. Co-Authored-By: Claude Sonnet 4.6 --- apps/hook/server/index.ts | 536 +++++-- packages/editor/App.tsx | 3120 +++++++++++++++++++++++-------------- 2 files changed, 2332 insertions(+), 1324 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 3eb91bda0..0b9eef23a 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -52,10 +52,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { - startPlannotatorServer, - handleServerReady, -} from "@plannotator/server"; +import { startPlannotatorServer, handleServerReady } from "@plannotator/server"; import { startReviewServer, handleReviewServerReady, @@ -64,17 +61,52 @@ import { startAnnotateServer, handleAnnotateServerReady, } from "@plannotator/server/annotate"; -import { type DiffType, prepareLocalReviewDiff, gitRuntime } from "@plannotator/server/vcs"; -import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config"; +import { + type DiffType, + prepareLocalReviewDiff, + gitRuntime, +} from "@plannotator/server/vcs"; +import { + loadConfig, + resolveDefaultDiffType, + resolveUseJina, +} from "@plannotator/shared/config"; import { parseReviewArgs } from "@plannotator/shared/review-args"; -import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-reference"; +import { + stripAtPrefix, + resolveAtReference, +} from "@plannotator/shared/at-reference"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; -import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown"; -import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree"; -import { createWorktreePool, type WorktreePool } from "@plannotator/shared/worktree-pool"; -import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; +import { + urlToMarkdown, + isConvertedSource, +} from "@plannotator/shared/url-to-markdown"; +import { + fetchRef, + createWorktree, + removeWorktree, + ensureObjectAvailable, +} from "@plannotator/shared/worktree"; +import { + createWorktreePool, + type WorktreePool, +} from "@plannotator/shared/worktree-pool"; +import { + parsePRUrl, + checkPRAuth, + fetchPR, + getCliName, + getCliInstallUrl, + getMRLabel, + getMRNumberLabel, + getDisplayRepo, +} from "@plannotator/server/pr"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; -import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; +import { + resolveMarkdownFile, + resolveUserPath, + hasMarkdownFiles, +} from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; import { statSync, rmSync, realpathSync, existsSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; @@ -85,7 +117,11 @@ import { getPlanToolName, buildPlanFileRule, } from "@plannotator/shared/prompts"; -import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions"; +import { + registerSession, + unregisterSession, + listSessions, +} from "@plannotator/server/sessions"; import { openBrowser } from "@plannotator/server/browser"; import { detectProjectName } from "@plannotator/server/project"; import { hostnameOrFallback } from "@plannotator/shared/project"; @@ -100,8 +136,16 @@ import { resolveSessionLogByCwdScan, type RenderedMessage, } from "./session-log"; -import { findCodexRolloutByThreadId, getLastCodexMessage, getLatestCodexPlan } from "./codex-session"; -import { findCopilotPlanContent, findCopilotSessionForCwd, getLastCopilotMessage } from "./copilot-session"; +import { + findCodexRolloutByThreadId, + getLastCodexMessage, + getLatestCodexPlan, +} from "./codex-session"; +import { + findCopilotPlanContent, + findCopilotSessionForCwd, + getLastCopilotMessage, +} from "./copilot-session"; import { formatInteractiveNoArgClarification, formatTopLevelHelp, @@ -111,7 +155,10 @@ import { isVersionInvocation, } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; -import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; +import { + shouldAutoSelectNativeClear, + spawnKeystrokeInjector, +} from "./keystrokeInjector"; import path from "path"; import { tmpdir } from "os"; @@ -185,7 +232,9 @@ function emitAnnotateOutcome(result: { if (hookFlag) { if (result.approved || result.exit) return; if (result.feedback) { - console.log(JSON.stringify({ decision: "block", reason: result.feedback })); + console.log( + JSON.stringify({ decision: "block", reason: result.feedback }), + ); } return; } @@ -195,7 +244,12 @@ function emitAnnotateOutcome(result: { } else if (result.exit) { console.log(JSON.stringify({ decision: "dismissed" })); } else { - console.log(JSON.stringify({ decision: "annotated", feedback: result.feedback || "" })); + console.log( + JSON.stringify({ + decision: "annotated", + feedback: result.feedback || "", + }), + ); } return; } @@ -247,12 +301,17 @@ const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined; // packages/shared/agents.ts (see header comment there). const originOverride = process.env.PLANNOTATOR_ORIGIN as Origin | undefined; const detectedOrigin: Origin = - (originOverride && originOverride in AGENT_CONFIG) ? originOverride : - process.env.CODEX_THREAD_ID ? "codex" : - process.env.COPILOT_CLI ? "copilot-cli" : - process.env.OPENCODE ? "opencode" : - process.env.GEMINI_CLI ? "gemini-cli" : - "claude-code"; + originOverride && originOverride in AGENT_CONFIG + ? originOverride + : process.env.CODEX_THREAD_ID + ? "codex" + : process.env.COPILOT_CLI + ? "copilot-cli" + : process.env.OPENCODE + ? "opencode" + : process.env.GEMINI_CLI + ? "gemini-cli" + : "claude-code"; if (args[0] === "sessions") { // ============================================ @@ -262,7 +321,9 @@ if (args[0] === "sessions") { if (args.includes("--clean")) { // Force cleanup: list sessions (which auto-removes stale entries) const sessions = listSessions(); - console.error(`Cleaned up stale sessions. ${sessions.length} active session(s) remain.`); + console.error( + `Cleaned up stale sessions. ${sessions.length} active session(s) remain.`, + ); process.exit(0); } @@ -280,7 +341,9 @@ if (args[0] === "sessions") { const n = nArg ? parseInt(nArg, 10) : 1; const session = sessions[n - 1]; if (!session) { - console.error(`Session #${n} not found. ${sessions.length} active session(s).`); + console.error( + `Session #${n} not found. ${sessions.length} active session(s).`, + ); process.exit(1); } await openBrowser(session.url); @@ -292,13 +355,17 @@ if (args[0] === "sessions") { console.error("Active Plannotator sessions:\n"); for (let i = 0; i < sessions.length; i++) { const s = sessions[i]; - const age = Math.round((Date.now() - new Date(s.startedAt).getTime()) / 60000); - const ageStr = age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`; - console.error(` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`); + const age = Math.round( + (Date.now() - new Date(s.startedAt).getTime()) / 60000, + ); + const ageStr = + age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`; + console.error( + ` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`, + ); } console.error(`\nReopen with: plannotator sessions --open [N]`); process.exit(0); - } else if (args[0] === "review") { // ============================================ // CODE REVIEW MODE @@ -312,7 +379,9 @@ if (args[0] === "sessions") { let rawPatch: string; let gitRef: string; let diffError: string | undefined; - let gitContext: Awaited>["gitContext"] | undefined; + let gitContext: + | Awaited>["gitContext"] + | undefined; let prMetadata: Awaited>["metadata"] | undefined; let initialDiffType: DiffType | undefined; let agentCwd: string | undefined; @@ -326,7 +395,9 @@ if (args[0] === "sessions") { console.error(`Invalid PR/MR URL: ${urlArg}`); console.error("Supported formats:"); console.error(" GitHub: https://github.com/owner/repo/pull/123"); - console.error(" GitLab: https://gitlab.com/group/project/-/merge_requests/42"); + console.error( + " GitLab: https://gitlab.com/group/project/-/merge_requests/42", + ); process.exit(1); } @@ -338,7 +409,9 @@ if (args[0] === "sessions") { } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("not found") || msg.includes("ENOENT")) { - console.error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`); + console.error( + `${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`, + ); console.error(`Install it from ${cliUrl}`); } else { console.error(msg); @@ -346,7 +419,9 @@ if (args[0] === "sessions") { process.exit(1); } - console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`); + console.error( + `Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`, + ); try { const pr = await fetchPR(prRef); rawPatch = pr.rawPatch; @@ -364,42 +439,68 @@ if (args[0] === "sessions") { let sessionDir: string | undefined; try { const repoDir = process.cwd(); - const identifier = prMetadata.platform === "github" - ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` - : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; + const identifier = + prMetadata.platform === "github" + ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` + : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; const suffix = Math.random().toString(36).slice(2, 8); // Resolve tmpdir to its real path — on macOS, tmpdir() returns /var/folders/... // but processes report /private/var/folders/... which breaks path stripping. - sessionDir = path.join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`); - const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; + sessionDir = path.join( + realpathSync(tmpdir()), + `plannotator-pr-${identifier}-${suffix}`, + ); + const prNumber = + prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; localPath = path.join(sessionDir, "pool", `pr-${prNumber}`); - const fetchRefStr = prMetadata.platform === "github" - ? `refs/pull/${prMetadata.number}/head` - : `refs/merge-requests/${prMetadata.iid}/head`; + const fetchRefStr = + prMetadata.platform === "github" + ? `refs/pull/${prMetadata.number}/head` + : `refs/merge-requests/${prMetadata.iid}/head`; // Validate inputs from platform API to prevent git flag/path injection - if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); - if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); + if ( + prMetadata.baseBranch.includes("..") || + prMetadata.baseBranch.startsWith("-") + ) + throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); + if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) + throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); // Detect same-repo vs cross-repo (must match both owner/repo AND host) let isSameRepo = false; try { - const remoteResult = await gitRuntime.runGit(["remote", "get-url", "origin"]); + const remoteResult = await gitRuntime.runGit([ + "remote", + "get-url", + "origin", + ]); if (remoteResult.exitCode === 0) { const remoteUrl = remoteResult.stdout.trim(); const currentRepo = parseRemoteUrl(remoteUrl); - const prRepo = prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; - const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase(); + const prRepo = + prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; + const repoMatches = + !!currentRepo && + currentRepo.toLowerCase() === prRepo.toLowerCase(); // Extract host from remote URL to avoid cross-instance false positives (GHE) const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1]; - const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })(); + const httpsHost = (() => { + try { + return new URL(remoteUrl).hostname; + } catch { + return null; + } + })(); const remoteHost = (sshHost || httpsHost || "").toLowerCase(); const prHost = prMetadata.host.toLowerCase(); isSameRepo = repoMatches && remoteHost === prHost; } - } catch { /* not in a git repo — cross-repo path */ } + } catch { + /* not in a git repo — cross-repo path */ + } if (isSameRepo) { // ── Same-repo: fast worktree path ── @@ -409,7 +510,9 @@ if (args[0] === "sessions") { // Both MUST happen before the PR head fetch since FETCH_HEAD is what // createWorktree uses — the PR head fetch must be last. await fetchRef(gitRuntime, prMetadata.baseBranch, { cwd: repoDir }); - await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { cwd: repoDir }); + await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { + cwd: repoDir, + }); // Fetch PR head LAST — sets FETCH_HEAD to the PR tip for createWorktree. await fetchRef(gitRuntime, fetchRefStr, { cwd: repoDir }); @@ -422,41 +525,65 @@ if (args[0] === "sessions") { worktreeCleanup = async () => { if (worktreePool) await worktreePool.cleanup(gitRuntime); - try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + try { + rmSync(sessionDir, { recursive: true, force: true }); + } catch {} }; process.once("exit", () => { // Best-effort sync cleanup: remove each pool worktree from git, then rm session dir try { for (const entry of worktreePool?.entries() ?? []) { - Bun.spawnSync(["git", "worktree", "remove", "--force", entry.path], { cwd: repoDir }); + Bun.spawnSync( + ["git", "worktree", "remove", "--force", entry.path], + { cwd: repoDir }, + ); } } catch {} - try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} + try { + Bun.spawnSync(["rm", "-rf", sessionDir]); + } catch {} }); } else { // ── Cross-repo: shallow clone + fetch PR head ── - const prRepo = prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; + const prRepo = + prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; // Validate repo identifier to prevent flag injection via crafted URLs - if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`); + if (/^-/.test(prRepo)) + throw new Error(`Invalid repository identifier: ${prRepo}`); const cli = prMetadata.platform === "github" ? "gh" : "glab"; const host = prMetadata.host; // gh/glab repo clone doesn't accept --hostname; set GH_HOST/GITLAB_HOST env instead const isDefaultHost = host === "github.com" || host === "gitlab.com"; - const cloneEnv = isDefaultHost ? undefined : { - ...process.env, - ...(prMetadata.platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }), - }; + const cloneEnv = isDefaultHost + ? undefined + : { + ...process.env, + ...(prMetadata.platform === "github" + ? { GH_HOST: host } + : { GITLAB_HOST: host }), + }; // Step 1: Fast skeleton clone (no checkout, depth 1 — minimal data transfer) console.error(`Cloning ${prRepo} (shallow)...`); const cloneResult = Bun.spawnSync( - [cli, "repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], + [ + cli, + "repo", + "clone", + prRepo, + localPath, + "--", + "--depth=1", + "--no-checkout", + ], { stderr: "pipe", env: cloneEnv }, ); if (cloneResult.exitCode !== 0) { - throw new Error(`${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`); + throw new Error( + `${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`, + ); } // Step 2: Fetch only the PR head ref (targeted, much faster than full fetch) @@ -465,23 +592,54 @@ if (args[0] === "sessions") { ["git", "fetch", "--depth=200", "origin", fetchRefStr], { cwd: localPath, stderr: "pipe" }, ); - if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`); + if (fetchResult.exitCode !== 0) + throw new Error( + `Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`, + ); // Step 3: Checkout PR head (critical — if this fails, worktree is empty) - const checkoutResult = Bun.spawnSync(["git", "checkout", "FETCH_HEAD"], { cwd: localPath, stderr: "pipe" }); + const checkoutResult = Bun.spawnSync( + ["git", "checkout", "FETCH_HEAD"], + { cwd: localPath, stderr: "pipe" }, + ); if (checkoutResult.exitCode !== 0) { - throw new Error(`git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`); + throw new Error( + `git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`, + ); } // Best-effort: create base refs so `git diff main...HEAD` and `git diff origin/main...HEAD` work - const baseFetch = Bun.spawnSync(["git", "fetch", "--depth=200", "origin", prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); - if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate"); - Bun.spawnSync(["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); - Bun.spawnSync(["git", "update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); + const baseFetch = Bun.spawnSync( + ["git", "fetch", "--depth=200", "origin", prMetadata.baseSha], + { cwd: localPath, stderr: "pipe" }, + ); + if (baseFetch.exitCode !== 0) + console.error( + "Warning: failed to fetch baseSha, agent diffs may be inaccurate", + ); + Bun.spawnSync( + ["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], + { cwd: localPath, stderr: "pipe" }, + ); + Bun.spawnSync( + [ + "git", + "update-ref", + `refs/remotes/origin/${prMetadata.baseBranch}`, + prMetadata.baseSha, + ], + { cwd: localPath, stderr: "pipe" }, + ); - worktreeCleanup = () => { try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} }; + worktreeCleanup = () => { + try { + rmSync(sessionDir, { recursive: true, force: true }); + } catch {} + }; process.once("exit", () => { - try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} + try { + Bun.spawnSync(["rm", "-rf", sessionDir]); + } catch {} }); } @@ -492,14 +650,22 @@ if (args[0] === "sessions") { // Create worktree pool with the initial PR as the first entry worktreePool = createWorktreePool( { sessionDir, repoDir, isSameRepo }, - { path: localPath, prUrl: prMetadata.url, number: prNumber, ready: true }, + { + path: localPath, + prUrl: prMetadata.url, + number: prNumber, + ready: true, + }, ); console.error(`Local checkout ready at ${localPath}`); } catch (err) { console.error(`Warning: --local failed, falling back to remote diff`); console.error(err instanceof Error ? err.message : String(err)); - if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + if (sessionDir) + try { + rmSync(sessionDir, { recursive: true, force: true }); + } catch {} agentCwd = undefined; worktreePool = undefined; worktreeCleanup = undefined; @@ -541,7 +707,12 @@ if (args[0] === "sessions") { handleReviewServerReady(url, isRemote, port); if (isRemote && sharingEnabled && rawPatch) { - await writeRemoteShareLink(rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => {}); + await writeRemoteShareLink( + rawPatch, + shareBaseUrl, + "review changes", + "diff only", + ).catch(() => {}); } }, }); @@ -553,7 +724,9 @@ if (args[0] === "sessions") { mode: "review", project: reviewProject, startedAt: new Date().toISOString(), - label: isPRMode ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` : `review-${reviewProject}`, + label: isPRMode + ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` + : `review-${reviewProject}`, }); // Wait for user feedback @@ -577,7 +750,6 @@ if (args[0] === "sessions") { } } process.exit(0); - } else if (args[0] === "annotate") { // ============================================ // ANNOTATE MODE @@ -585,7 +757,9 @@ if (args[0] === "sessions") { const rawFilePath = args[1]; if (!rawFilePath) { - console.error("Usage: plannotator annotate [--no-jina] [--gate] [--json] [--hook]"); + console.error( + "Usage: plannotator annotate [--no-jina] [--gate] [--json] [--hook]", + ); process.exit(1); } @@ -615,31 +789,46 @@ if (args[0] === "sessions") { if (isUrl) { const useJina = resolveUseJina(cliNoJina, loadConfig()); - console.error(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`); + console.error( + `Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`, + ); try { const result = await urlToMarkdown(filePath, { useJina }); markdown = result.markdown; sourceConverted = isConvertedSource(result.source); if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`); + console.error( + `[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`, + ); } } catch (err) { - console.error(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`); + console.error( + `Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`, + ); process.exit(1); } absolutePath = filePath; // Use URL as the "path" for display - sourceInfo = filePath; // Full URL for source attribution + sourceInfo = filePath; // Full URL for source attribution } else { // Folder check with literal-@ fallback for scoped-package-style names. const folderCandidate = resolveAtReference(rawFilePath, (c) => { - try { return statSync(resolveUserPath(c, projectRoot)).isDirectory(); } - catch { return false; } + try { + return statSync(resolveUserPath(c, projectRoot)).isDirectory(); + } catch { + return false; + } }); if (folderCandidate !== null) { const resolvedArg = resolveUserPath(folderCandidate, projectRoot); // Folder annotation mode (markdown + HTML files) - if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) { + if ( + !hasMarkdownFiles( + resolvedArg, + FILE_BROWSER_EXCLUDED, + /\.(mdx?|html?)$/i, + ) + ) { console.error(`No markdown or HTML files found in ${resolvedArg}`); process.exit(1); } @@ -659,7 +848,9 @@ if (args[0] === "sessions") { const resolvedArg = resolveUserPath(htmlCandidate, projectRoot); const htmlFile = Bun.file(resolvedArg); if (htmlFile.size > 10 * 1024 * 1024) { - console.error(`File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`); + console.error( + `File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`, + ); process.exit(1); } const html = await htmlFile.text(); @@ -672,7 +863,9 @@ if (args[0] === "sessions") { } absolutePath = resolvedArg; sourceInfo = path.basename(resolvedArg); - console.error(`${renderHtmlFlag ? "Raw HTML" : "Converted"}: ${absolutePath}`); + console.error( + `${renderHtmlFlag ? "Raw HTML" : "Converted"}: ${absolutePath}`, + ); } else { // Single markdown file annotation mode // Strip-first with literal-@ fallback (scoped-package-style names). @@ -682,7 +875,9 @@ if (args[0] === "sessions") { } if (resolved.kind === "ambiguous") { - console.error(`Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`); + console.error( + `Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`, + ); for (const match of resolved.matches) { console.error(` ${match}`); } @@ -722,7 +917,12 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled && markdown) { - await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {}); + await writeRemoteShareLink( + markdown, + shareBaseUrl, + "annotate", + "document only", + ).catch(() => {}); } }, }); @@ -751,7 +951,6 @@ if (args[0] === "sessions") { // Output feedback (captured by slash command) emitAnnotateOutcome(result); process.exit(0); - } else if (args[0] === "annotate-last" || args[0] === "last") { // ============================================ // ANNOTATE LAST MESSAGE MODE @@ -775,7 +974,11 @@ if (args[0] === "sessions") { } const msg = getLastCodexMessage(rolloutPath); if (msg) { - lastMessage = { messageId: codexThreadId, text: msg.text, lineNumbers: [] }; + lastMessage = { + messageId: codexThreadId, + text: msg.text, + lineNumbers: [], + }; } } } else { @@ -805,7 +1008,9 @@ if (args[0] === "sessions") { if (lastMessage) return; const paths = getPaths(); if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`); + console.error( + `[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`, + ); } for (const logPath of paths) { lastMessage = getLastRenderedMessage(logPath); @@ -815,17 +1020,25 @@ if (args[0] === "sessions") { // 1. Walk ancestor PIDs for a matching session metadata file const ancestorLog = resolveSessionLogByAncestorPids(); - tryLogCandidates("Ancestor PID session metadata", () => ancestorLog ? [ancestorLog] : []); + tryLogCandidates("Ancestor PID session metadata", () => + ancestorLog ? [ancestorLog] : [], + ); // 2. Scan all session metadata files for one whose cwd matches const cwdScanLog = resolveSessionLogByCwdScan({ cwd: projectRoot }); - tryLogCandidates("Cwd-scan session metadata", () => cwdScanLog ? [cwdScanLog] : []); + tryLogCandidates("Cwd-scan session metadata", () => + cwdScanLog ? [cwdScanLog] : [], + ); // 3. Fall back to CWD slug match (mtime-based) - tryLogCandidates("CWD slug match (mtime)", () => findSessionLogsForCwd(projectRoot)); + tryLogCandidates("CWD slug match (mtime)", () => + findSessionLogsForCwd(projectRoot), + ); // 4. Fall back to ancestor directory walk - tryLogCandidates("Directory ancestor walk", () => findSessionLogsByAncestorWalk(projectRoot)); + tryLogCandidates("Directory ancestor walk", () => + findSessionLogsByAncestorWalk(projectRoot), + ); } if (!lastMessage) { @@ -834,7 +1047,9 @@ if (args[0] === "sessions") { } if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`); + console.error( + `[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`, + ); } const annotateProject = (await detectProjectName()) ?? "_unknown"; @@ -853,7 +1068,12 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(lastMessage.text, shareBaseUrl, "annotate", "message only").catch(() => {}); + await writeRemoteShareLink( + lastMessage.text, + shareBaseUrl, + "annotate", + "message only", + ).catch(() => {}); } }, }); @@ -876,7 +1096,6 @@ if (args[0] === "sessions") { emitAnnotateOutcome(result); process.exit(0); - } else if (args[0] === "archive") { // ============================================ // ARCHIVE BROWSER MODE @@ -911,7 +1130,6 @@ if (args[0] === "sessions") { await Bun.sleep(500); server.stop(); process.exit(0); - } else if (args[0] === "copilot-plan") { // ============================================ // COPILOT CLI PLAN INTERCEPTION MODE @@ -922,7 +1140,13 @@ if (args[0] === "sessions") { // No output = allow the tool call to proceed. const eventJson = await Bun.stdin.text(); - let event: { toolName: string; toolArgs: string; cwd: string; timestamp: number; sessionId?: string }; + let event: { + toolName: string; + toolArgs: string; + cwd: string; + timestamp: number; + sessionId?: string; + }; try { event = JSON.parse(eventJson); @@ -957,7 +1181,12 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink( + planContent, + shareBaseUrl, + "review the plan", + "plan only", + ).catch(() => {}); } }, }); @@ -978,23 +1207,26 @@ if (args[0] === "sessions") { // Output Copilot CLI permission decision format if (result.approved) { - console.log(JSON.stringify({ - permissionDecision: "allow", - })); + console.log( + JSON.stringify({ + permissionDecision: "allow", + }), + ); } else { const feedback = getPlanDeniedPrompt("copilot-cli", undefined, { toolName: getPlanToolName("copilot-cli"), planFileRule: "", feedback: result.feedback || "Plan changes requested", }); - console.log(JSON.stringify({ - permissionDecision: "deny", - permissionDecisionReason: feedback, - })); + console.log( + JSON.stringify({ + permissionDecision: "deny", + permissionDecisionReason: feedback, + }), + ); } process.exit(0); - } else if (args[0] === "copilot-last") { // ============================================ // COPILOT CLI ANNOTATE LAST MESSAGE MODE @@ -1003,7 +1235,9 @@ if (args[0] === "sessions") { const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`); + console.error( + `[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`, + ); } const sessionDir = findCopilotSessionForCwd(projectRoot); @@ -1042,7 +1276,12 @@ if (args[0] === "sessions") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(msg.text, shareBaseUrl, "annotate", "message only").catch(() => {}); + await writeRemoteShareLink( + msg.text, + shareBaseUrl, + "annotate", + "message only", + ).catch(() => {}); } }, }); @@ -1063,7 +1302,6 @@ if (args[0] === "sessions") { emitAnnotateOutcome(result); process.exit(0); - } else if (args[0] === "improve-context") { // ============================================ // IMPROVEMENT HOOK CONTEXT INJECTION MODE @@ -1086,15 +1324,16 @@ if (args[0] === "sessions") { if (context === null) process.exit(0); - console.log(JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PreToolUse", - additionalContext: context, - }, - })); + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: context, + }, + }), + ); process.exit(0); - } else { // ============================================ // PLAN REVIEW MODE (default) @@ -1146,7 +1385,12 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(latestPlan.text, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink( + latestPlan.text, + shareBaseUrl, + "review the plan", + "plan only", + ).catch(() => {}); } }, }); @@ -1176,7 +1420,7 @@ if (args[0] === "sessions") { planFileRule: "", feedback: result.feedback || "Plan changes requested", }), - }) + }), ); } @@ -1189,7 +1433,8 @@ if (args[0] === "sessions") { let planFilename = ""; // Detect harness: Gemini sends plan_filename (file on disk), Claude Code sends plan (inline) - planFilename = event.tool_input?.plan_filename || event.tool_input?.plan_path || ""; + planFilename = + event.tool_input?.plan_filename || event.tool_input?.plan_path || ""; isGemini = !!planFilename; if (isGemini) { @@ -1197,7 +1442,12 @@ if (args[0] === "sessions") { // transcript_path = /chats/session-...json // plan lives at = //plans/ const projectTempDir = path.dirname(path.dirname(event.transcript_path)); - const planFilePath = path.join(projectTempDir, event.session_id, "plans", planFilename); + const planFilePath = path.join( + projectTempDir, + event.session_id, + "plans", + planFilename, + ); planContent = await Bun.file(planFilePath).text(); } else { planContent = event.tool_input?.plan || ""; @@ -1232,7 +1482,12 @@ if (args[0] === "sessions") { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink( + planContent, + shareBaseUrl, + "review the plan", + "plan only", + ).catch(() => {}); } }, }); @@ -1259,26 +1514,37 @@ if (args[0] === "sessions") { // Output decision in the appropriate format for the harness if (isGemini) { if (result.approved) { - console.log(result.feedback ? JSON.stringify({ systemMessage: result.feedback }) : "{}"); + console.log( + result.feedback + ? JSON.stringify({ systemMessage: result.feedback }) + : "{}", + ); } else { console.log( JSON.stringify({ decision: "deny", reason: getPlanDeniedPrompt("gemini-cli", undefined, { toolName: getPlanToolName("gemini-cli"), - planFileRule: buildPlanFileRule(getPlanToolName("gemini-cli"), planFilename), + planFileRule: buildPlanFileRule( + getPlanToolName("gemini-cli"), + planFilename, + ), feedback: result.feedback || "Plan changes requested", }), - }) + }), ); } } else { // Claude Code: PermissionRequest hook decision if ( result.approved && - result.deferToNativeForClear && - toolName === "ExitPlanMode" + toolName === "ExitPlanMode" && + (result.deferToNativeForClear || result.permissionMode === "deferNative") ) { + // Step aside: emit nothing so CC shows its own native plan dialog + // (which offers clear-context + bypass). behavior:"defer" is NOT valid + // here — it belongs on HookPermissionDecision (PreToolUse), not on + // PermissionRequestHookSpecificOutput.decision. const nativeClearEnabled = await ensureClearContextSettingEnabled(); if (nativeClearEnabled) { if (shouldAutoSelectNativeClear()) { @@ -1287,7 +1553,7 @@ if (args[0] === "sessions") { process.exit(0); } result.clearContextNudge = true; - result.permissionMode ||= "bypassPermissions"; + result.permissionMode = "bypassPermissions"; } if (result.approved) { @@ -1313,7 +1579,7 @@ if (args[0] === "sessions") { ...(updatedPermissions.length > 0 && { updatedPermissions }), }, }, - }) + }), ); } else { console.log( @@ -1329,7 +1595,7 @@ if (args[0] === "sessions") { }), }, }, - }) + }), ); } } diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 4c321364f..a90d2b181 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -1,90 +1,157 @@ -import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; -import { toast, Toaster } from 'sonner'; -import { type Origin, getAgentName } from '@plannotator/shared/agents'; -import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, exportCodeFileAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter, type LinkedDocAnnotationEntry } from '@plannotator/ui/utils/parser'; -import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer'; -import { HtmlViewer } from '@plannotator/ui/components/html-viewer'; -import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel'; -import { ExportModal } from '@plannotator/ui/components/ExportModal'; -import { ImportModal } from '@plannotator/ui/components/ImportModal'; -import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; -import { Annotation, Block, EditorMode, type CodeAnnotation, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '@plannotator/ui/types'; -import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; -import { Tooltip, TooltipProvider } from '@plannotator/ui/components/Tooltip'; -import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolstrip'; -import { StickyHeaderLane } from '@plannotator/ui/components/StickyHeaderLane'; -import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning'; -import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup'; -import { useSharing } from '@plannotator/ui/hooks/useSharing'; -import { getCallbackConfig, CallbackAction, executeCallback } from '@plannotator/ui/utils/callback'; -import { useAgents } from '@plannotator/ui/hooks/useAgents'; -import { useActiveSection } from '@plannotator/ui/hooks/useActiveSection'; -import { storage } from '@plannotator/ui/utils/storage'; -import { configStore } from '@plannotator/ui/config'; -import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; -import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; -import { getObsidianSettings, getEffectiveVaultPath, isObsidianConfigured, CUSTOM_PATH_SENTINEL } from '@plannotator/ui/utils/obsidian'; -import { getBearSettings } from '@plannotator/ui/utils/bear'; -import { getOctarineSettings, isOctarineConfigured } from '@plannotator/ui/utils/octarine'; -import { getDefaultNotesApp } from '@plannotator/ui/utils/defaultNotesApp'; -import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; -import { getPlanSaveSettings } from '@plannotator/ui/utils/planSave'; -import { getUIPreferences, type UIPreferences, type PlanWidth } from '@plannotator/ui/utils/uiPreferences'; -import { getEditorMode, saveEditorMode } from '@plannotator/ui/utils/editorMode'; -import { getInputMethod, saveInputMethod } from '@plannotator/ui/utils/inputMethod'; -import { useInputMethodSwitch } from '@plannotator/ui/hooks/useInputMethodSwitch'; -import { usePrintMode } from '@plannotator/ui/hooks/usePrintMode'; -import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel'; -import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; -import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'; -import { ScrollViewportContext } from '@plannotator/ui/hooks/useScrollViewport'; -import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport'; -import { useIsMobile } from '@plannotator/ui/hooks/useIsMobile'; +import React, { + useState, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useCallback, +} from "react"; +import { toast, Toaster } from "sonner"; +import { type Origin, getAgentName } from "@plannotator/shared/agents"; +import { + parseMarkdownToBlocks, + exportAnnotations, + exportLinkedDocAnnotations, + exportEditorAnnotations, + exportCodeFileAnnotations, + extractFrontmatter, + wrapFeedbackForAgent, + Frontmatter, + type LinkedDocAnnotationEntry, +} from "@plannotator/ui/utils/parser"; +import { Viewer, ViewerHandle } from "@plannotator/ui/components/Viewer"; +import { HtmlViewer } from "@plannotator/ui/components/html-viewer"; +import { AnnotationPanel } from "@plannotator/ui/components/AnnotationPanel"; +import { ExportModal } from "@plannotator/ui/components/ExportModal"; +import { ImportModal } from "@plannotator/ui/components/ImportModal"; +import { ConfirmDialog } from "@plannotator/ui/components/ConfirmDialog"; +import { + Annotation, + Block, + EditorMode, + type CodeAnnotation, + type InputMethod, + type ImageAttachment, + type ActionsLabelMode, +} from "@plannotator/ui/types"; +import { ThemeProvider } from "@plannotator/ui/components/ThemeProvider"; +import { Tooltip, TooltipProvider } from "@plannotator/ui/components/Tooltip"; +import { AnnotationToolstrip } from "@plannotator/ui/components/AnnotationToolstrip"; +import { StickyHeaderLane } from "@plannotator/ui/components/StickyHeaderLane"; +import { TaterSpriteRunning } from "@plannotator/ui/components/TaterSpriteRunning"; +import { TaterSpritePullup } from "@plannotator/ui/components/TaterSpritePullup"; +import { useSharing } from "@plannotator/ui/hooks/useSharing"; +import { + getCallbackConfig, + CallbackAction, + executeCallback, +} from "@plannotator/ui/utils/callback"; +import { useAgents } from "@plannotator/ui/hooks/useAgents"; +import { useActiveSection } from "@plannotator/ui/hooks/useActiveSection"; +import { storage } from "@plannotator/ui/utils/storage"; +import { configStore } from "@plannotator/ui/config"; +import { CompletionOverlay } from "@plannotator/ui/components/CompletionOverlay"; +import { UpdateBanner } from "@plannotator/ui/components/UpdateBanner"; +import { + getObsidianSettings, + getEffectiveVaultPath, + isObsidianConfigured, + CUSTOM_PATH_SENTINEL, +} from "@plannotator/ui/utils/obsidian"; +import { getBearSettings } from "@plannotator/ui/utils/bear"; +import { + getOctarineSettings, + isOctarineConfigured, +} from "@plannotator/ui/utils/octarine"; +import { getDefaultNotesApp } from "@plannotator/ui/utils/defaultNotesApp"; +import { + getAgentSwitchSettings, + getEffectiveAgentName, +} from "@plannotator/ui/utils/agentSwitch"; +import { getPlanSaveSettings } from "@plannotator/ui/utils/planSave"; +import { + getUIPreferences, + type UIPreferences, + type PlanWidth, +} from "@plannotator/ui/utils/uiPreferences"; +import { + getEditorMode, + saveEditorMode, +} from "@plannotator/ui/utils/editorMode"; +import { + getInputMethod, + saveInputMethod, +} from "@plannotator/ui/utils/inputMethod"; +import { useInputMethodSwitch } from "@plannotator/ui/hooks/useInputMethodSwitch"; +import { usePrintMode } from "@plannotator/ui/hooks/usePrintMode"; +import { useResizablePanel } from "@plannotator/ui/hooks/useResizablePanel"; +import { ResizeHandle } from "@plannotator/ui/components/ResizeHandle"; +import { OverlayScrollArea } from "@plannotator/ui/components/OverlayScrollArea"; +import { ScrollViewportContext } from "@plannotator/ui/hooks/useScrollViewport"; +import { useOverlayViewport } from "@plannotator/ui/hooks/useOverlayViewport"; +import { useIsMobile } from "@plannotator/ui/hooks/useIsMobile"; import { getPermissionModeSettings, needsPermissionModeSetup, type PermissionMode, -} from '@plannotator/ui/utils/permissionMode'; -import { PermissionModeSetup } from '@plannotator/ui/components/PermissionModeSetup'; -import { ImageAnnotator } from '@plannotator/ui/components/ImageAnnotator'; -import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton'; -import { useSidebar, type SidebarTab } from '@plannotator/ui/hooks/useSidebar'; -import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff'; -import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc'; -import { useCodeFilePopout } from '@plannotator/ui/hooks/useCodeFilePopout'; -import { useAnnotationDraft } from '@plannotator/ui/hooks/useAnnotationDraft'; -import { useArchive } from '@plannotator/ui/hooks/useArchive'; -import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; -import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations'; -import { useExternalAnnotationHighlights } from '@plannotator/ui/hooks/useExternalAnnotationHighlights'; -import { buildPlanAgentInstructions } from '@plannotator/ui/utils/planAgentInstructions'; -import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser'; -import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian'; -import { isFileBrowserEnabled, getFileBrowserSettings } from '@plannotator/ui/utils/fileBrowser'; -import { generateId } from '@plannotator/ui/utils/generateId'; -import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs'; -import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer'; -import type { ArchivedPlan } from '@plannotator/ui/components/sidebar/ArchiveBrowser'; -import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer'; -import { CodeFilePopout, type CodeFileAnnotationInput } from '@plannotator/ui/components/CodeFilePopout'; -import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher'; +} from "@plannotator/ui/utils/permissionMode"; +import { PermissionModeSetup } from "@plannotator/ui/components/PermissionModeSetup"; +import { ImageAnnotator } from "@plannotator/ui/components/ImageAnnotator"; +import { deriveImageName } from "@plannotator/ui/components/AttachmentsButton"; +import { useSidebar, type SidebarTab } from "@plannotator/ui/hooks/useSidebar"; +import { + usePlanDiff, + type VersionInfo, +} from "@plannotator/ui/hooks/usePlanDiff"; +import { useLinkedDoc } from "@plannotator/ui/hooks/useLinkedDoc"; +import { useCodeFilePopout } from "@plannotator/ui/hooks/useCodeFilePopout"; +import { useAnnotationDraft } from "@plannotator/ui/hooks/useAnnotationDraft"; +import { useArchive } from "@plannotator/ui/hooks/useArchive"; +import { useEditorAnnotations } from "@plannotator/ui/hooks/useEditorAnnotations"; +import { useExternalAnnotations } from "@plannotator/ui/hooks/useExternalAnnotations"; +import { useExternalAnnotationHighlights } from "@plannotator/ui/hooks/useExternalAnnotationHighlights"; +import { buildPlanAgentInstructions } from "@plannotator/ui/utils/planAgentInstructions"; +import { useFileBrowser } from "@plannotator/ui/hooks/useFileBrowser"; +import { isVaultBrowserEnabled } from "@plannotator/ui/utils/obsidian"; +import { + isFileBrowserEnabled, + getFileBrowserSettings, +} from "@plannotator/ui/utils/fileBrowser"; +import { generateId } from "@plannotator/ui/utils/generateId"; +import { SidebarTabs } from "@plannotator/ui/components/sidebar/SidebarTabs"; +import { SidebarContainer } from "@plannotator/ui/components/sidebar/SidebarContainer"; +import type { ArchivedPlan } from "@plannotator/ui/components/sidebar/ArchiveBrowser"; +import { PlanDiffViewer } from "@plannotator/ui/components/plan-diff/PlanDiffViewer"; +import { + CodeFilePopout, + type CodeFileAnnotationInput, +} from "@plannotator/ui/components/CodeFilePopout"; +import type { PlanDiffMode } from "@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher"; // Demo content toggle. Default: the original Real-time Collaboration plan. // Opt-in diff-engine stress test: `VITE_DIFF_DEMO=1 bun run dev:hook` swaps // in the 20-case Auth Service Refactor test plan. dev-mock-api.ts reads the // same env var on the server side so V2/V3 stay paired. -import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from './demoPlan'; -import { DIFF_DEMO_PLAN_CONTENT } from './demoPlanDiffDemo'; -import { canUseAnnotateWideMode, resolveWideModeExitLayout, type WideModeLayoutSnapshot, type WideModeType } from './wideMode'; -import { buildApprovalRequestBody, type ApprovalOverride } from './approvalBody'; -import type { ApproveExtraEntry } from '@plannotator/ui/components/ApproveDropdown'; +import { DEMO_PLAN_CONTENT as DEFAULT_DEMO_PLAN_CONTENT } from "./demoPlan"; +import { DIFF_DEMO_PLAN_CONTENT } from "./demoPlanDiffDemo"; +import { + canUseAnnotateWideMode, + resolveWideModeExitLayout, + type WideModeLayoutSnapshot, + type WideModeType, +} from "./wideMode"; +import { + buildApprovalRequestBody, + type ApprovalOverride, +} from "./approvalBody"; +import type { ApproveExtraEntry } from "@plannotator/ui/components/ApproveDropdown"; const USE_DIFF_DEMO = - import.meta.env.VITE_DIFF_DEMO === '1' || - import.meta.env.VITE_DIFF_DEMO === 'true'; + import.meta.env.VITE_DIFF_DEMO === "1" || + import.meta.env.VITE_DIFF_DEMO === "true"; const DEMO_PLAN_CONTENT = USE_DIFF_DEMO ? DIFF_DEMO_PLAN_CONTENT : DEFAULT_DEMO_PLAN_CONTENT; -import { useCheckboxOverrides } from './hooks/useCheckboxOverrides'; -import { AppHeader } from './components/AppHeader'; +import { useCheckboxOverrides } from "./hooks/useCheckboxOverrides"; +import { AppHeader } from "./components/AppHeader"; type NoteAutoSaveResults = { obsidian?: boolean; @@ -96,9 +163,16 @@ const App: React.FC = () => { const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); const [annotations, setAnnotations] = useState([]); const [codeAnnotations, setCodeAnnotations] = useState([]); - const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); - const [selectedCodeAnnotationId, setSelectedCodeAnnotationId] = useState(null); - const frontmatter = useMemo(() => extractFrontmatter(markdown).frontmatter, [markdown]); + const [selectedAnnotationId, setSelectedAnnotationId] = useState< + string | null + >(null); + const [selectedCodeAnnotationId, setSelectedCodeAnnotationId] = useState< + string | null + >(null); + const frontmatter = useMemo( + () => extractFrontmatter(markdown).frontmatter, + [markdown], + ); const blocks = useMemo(() => parseMarkdownToBlocks(markdown), [markdown]); const [showExport, setShowExport] = useState(false); const [showImport, setShowImport] = useState(false); @@ -106,16 +180,20 @@ const App: React.FC = () => { const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false); const [showExitWarning, setShowExitWarning] = useState(false); // When the warning dialog confirms, route to the handler matching the button that opened it. - const [exitWarningAction, setExitWarningAction] = useState<'close' | 'approve'>('close'); + const [exitWarningAction, setExitWarningAction] = useState< + "close" | "approve" + >("close"); const [showAgentWarning, setShowAgentWarning] = useState(false); - const [agentWarningMessage, setAgentWarningMessage] = useState(''); - const [isPanelOpen, setIsPanelOpen] = useState(() => window.innerWidth >= 768); + const [agentWarningMessage, setAgentWarningMessage] = useState(""); + const [isPanelOpen, setIsPanelOpen] = useState( + () => window.innerWidth >= 768, + ); const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false); const [editorMode, setEditorMode] = useState(getEditorMode); const [inputMethod, setInputMethod] = useState(getInputMethod); const [taterMode, setTaterMode] = useState(() => { - const stored = storage.getItem('plannotator-tater-mode'); - return stored === 'true'; + const stored = storage.getItem("plannotator-tater-mode"); + return stored === "true"; }); const [uiPrefs, setUiPrefs] = useState(() => getUIPreferences()); @@ -130,7 +208,8 @@ const App: React.FC = () => { // short → "Comment" / "Copy" — fits when planArea >= 680 // icon → labels hidden — fallback below that const planAreaRef = useRef(null); - const [actionsLabelMode, setActionsLabelMode] = useState('full'); + const [actionsLabelMode, setActionsLabelMode] = + useState("full"); // useLayoutEffect + synchronous getBoundingClientRect so the initial // bucket is set before the browser paints. Otherwise narrow viewports // get a one-frame flash of "Global comment"/"Copy plan" labels before @@ -139,7 +218,7 @@ const App: React.FC = () => { const el = planAreaRef.current; if (!el) return; const bucket = (w: number): ActionsLabelMode => - w >= 800 ? 'full' : w >= 680 ? 'short' : 'icon'; + w >= 800 ? "full" : w >= 680 ? "short" : "icon"; setActionsLabelMode(bucket(el.getBoundingClientRect().width)); const ro = new ResizeObserver(([entry]) => { const next = bucket(entry.contentRect.width); @@ -152,42 +231,66 @@ const App: React.FC = () => { const [origin, setOrigin] = useState(null); const [pendingToolName, setPendingToolName] = useState(); const [showClearContextBanner, setShowClearContextBanner] = useState(false); - const [pendingApprovalOverride, setPendingApprovalOverride] = useState({}); + const [pendingApprovalOverride, setPendingApprovalOverride] = + useState({}); const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); - const [globalAttachments, setGlobalAttachments] = useState([]); + const [globalAttachments, setGlobalAttachments] = useState( + [], + ); const [annotateMode, setAnnotateMode] = useState(false); const [gate, setGate] = useState(false); - const [annotateSource, setAnnotateSource] = useState<'file' | 'message' | 'folder' | null>(null); + const [annotateSource, setAnnotateSource] = useState< + "file" | "message" | "folder" | null + >(null); const [sourceInfo, setSourceInfo] = useState(); const [sourceConverted, setSourceConverted] = useState(false); - const [renderAs, setRenderAs] = useState<'markdown' | 'html'>('markdown'); - const [rawHtml, setRawHtml] = useState(''); + const [renderAs, setRenderAs] = useState<"markdown" | "html">("markdown"); + const [rawHtml, setRawHtml] = useState(""); const [sourceFilePath, setSourceFilePath] = useState(); - const [imageBaseDir, setImageBaseDir] = useState(undefined); + const [imageBaseDir, setImageBaseDir] = useState( + undefined, + ); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [isExiting, setIsExiting] = useState(false); - const [submitted, setSubmitted] = useState<'approved' | 'denied' | 'exited' | null>(null); - const [pendingPasteImage, setPendingPasteImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null); + const [submitted, setSubmitted] = useState< + "approved" | "denied" | "exited" | null + >(null); + const [pendingPasteImage, setPendingPasteImage] = useState<{ + file: File; + blobUrl: string; + initialName: string; + } | null>(null); const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false); - const [permissionMode, setPermissionMode] = useState('bypassPermissions'); + const [permissionMode, setPermissionMode] = + useState("bypassPermissions"); const [sharingEnabled, setSharingEnabled] = useState(true); - const [shareBaseUrl, setShareBaseUrl] = useState(undefined); + const [shareBaseUrl, setShareBaseUrl] = useState( + undefined, + ); const [pasteApiUrl, setPasteApiUrl] = useState(undefined); - const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string; host?: string } | null>(null); + const [repoInfo, setRepoInfo] = useState<{ + display: string; + branch?: string; + host?: string; + } | null>(null); const [projectRoot, setProjectRoot] = useState(null); const [wideModeType, setWideModeType] = useState(null); const wideModeSnapshotRef = useRef(null); const lastAppliedTocEnabledRef = useRef(uiPrefs.tocEnabled); useEffect(() => { - document.title = repoInfo ? `${repoInfo.display} · Plannotator` : "Plannotator"; + document.title = repoInfo + ? `${repoInfo.display} · Plannotator` + : "Plannotator"; }, [repoInfo]); - const [initialExportTab, setInitialExportTab] = useState<'share' | 'annotations' | 'notes'>(); + const [initialExportTab, setInitialExportTab] = useState< + "share" | "annotations" | "notes" + >(); const [isPlanDiffActive, setIsPlanDiffActive] = useState(false); - const [planDiffMode, setPlanDiffMode] = useState('clean'); + const [planDiffMode, setPlanDiffMode] = useState("clean"); const [previousPlan, setPreviousPlan] = useState(null); const [versionInfo, setVersionInfo] = useState(null); const isMobile = useIsMobile(); @@ -206,10 +309,15 @@ const App: React.FC = () => { usePrintMode(); // Resizable panels - const panelResize = useResizablePanel({ storageKey: 'plannotator-panel-width' }); + const panelResize = useResizablePanel({ + storageKey: "plannotator-panel-width", + }); const tocResize = useResizablePanel({ - storageKey: 'plannotator-toc-width', - defaultWidth: 240, minWidth: 160, maxWidth: 400, side: 'left', + storageKey: "plannotator-toc-width", + defaultWidth: 240, + minWidth: 160, + maxWidth: 400, + side: "left", }); const isResizing = panelResize.isDragging || tocResize.isDragging; @@ -220,61 +328,70 @@ const App: React.FC = () => { // buildTocHierarchy). Drives the empty-doc auto-close behavior below — must // be declared before the effects that reference it (TDZ in dep arrays). const hasTocEntries = useMemo( - () => blocks.some(b => b.type === 'heading' && (b.level ?? 0) <= 3), - [blocks] + () => blocks.some((b) => b.type === "heading" && (b.level ?? 0) <= 3), + [blocks], ); - const exitWideMode = useCallback((options?: { - restore?: boolean; - sidebarTab?: SidebarTab; - panelOpen?: boolean; - }) => { - if (wideModeType === null) { - if (options?.sidebarTab) sidebar.open(options.sidebarTab); - if (options?.panelOpen === true) setIsPanelOpen(true); - else if (options?.panelOpen === false) setIsPanelOpen(false); - return; - } + const exitWideMode = useCallback( + (options?: { + restore?: boolean; + sidebarTab?: SidebarTab; + panelOpen?: boolean; + }) => { + if (wideModeType === null) { + if (options?.sidebarTab) sidebar.open(options.sidebarTab); + if (options?.panelOpen === true) setIsPanelOpen(true); + else if (options?.panelOpen === false) setIsPanelOpen(false); + return; + } - const snapshot = wideModeSnapshotRef.current; - const layout = resolveWideModeExitLayout(snapshot, options); + const snapshot = wideModeSnapshotRef.current; + const layout = resolveWideModeExitLayout(snapshot, options); - setWideModeType(null); - wideModeSnapshotRef.current = null; + setWideModeType(null); + wideModeSnapshotRef.current = null; - if (layout.sidebarOpen && layout.sidebarTab) { - sidebar.open(layout.sidebarTab); - } else { - sidebar.close(); - } + if (layout.sidebarOpen && layout.sidebarTab) { + sidebar.open(layout.sidebarTab); + } else { + sidebar.close(); + } - if (layout.panelOpen !== undefined) { - setIsPanelOpen(layout.panelOpen); - } - }, [wideModeType, sidebar.close, sidebar.open]); + if (layout.panelOpen !== undefined) { + setIsPanelOpen(layout.panelOpen); + } + }, + [wideModeType, sidebar.close, sidebar.open], + ); - const openSidebarTab = useCallback((tab: SidebarTab) => { - if (wideModeType !== null) { - exitWideMode({ restore: false, sidebarTab: tab, panelOpen: false }); - return; - } - sidebar.open(tab); - }, [exitWideMode, wideModeType, sidebar.open]); + const openSidebarTab = useCallback( + (tab: SidebarTab) => { + if (wideModeType !== null) { + exitWideMode({ restore: false, sidebarTab: tab, panelOpen: false }); + return; + } + sidebar.open(tab); + }, + [exitWideMode, wideModeType, sidebar.open], + ); - const toggleSidebarTab = useCallback((tab: SidebarTab) => { - if (wideModeType !== null) { - exitWideMode({ restore: false, sidebarTab: tab, panelOpen: false }); - return; - } - sidebar.toggleTab(tab); - }, [exitWideMode, wideModeType, sidebar.toggleTab]); + const toggleSidebarTab = useCallback( + (tab: SidebarTab) => { + if (wideModeType !== null) { + exitWideMode({ restore: false, sidebarTab: tab, panelOpen: false }); + return; + } + sidebar.toggleTab(tab); + }, + [exitWideMode, wideModeType, sidebar.toggleTab], + ); const handleAnnotationPanelToggle = useCallback(() => { if (wideModeType !== null) { exitWideMode({ restore: false, panelOpen: true }); return; } - setIsPanelOpen(prev => !prev); + setIsPanelOpen((prev) => !prev); }, [exitWideMode, wideModeType]); // Sync sidebar open state when preference changes in Settings @@ -282,9 +399,15 @@ const App: React.FC = () => { if (wideModeType !== null) return; if (lastAppliedTocEnabledRef.current === uiPrefs.tocEnabled) return; lastAppliedTocEnabledRef.current = uiPrefs.tocEnabled; - if (uiPrefs.tocEnabled && hasTocEntries) sidebar.open('toc'); + if (uiPrefs.tocEnabled && hasTocEntries) sidebar.open("toc"); else if (!uiPrefs.tocEnabled) sidebar.close(); - }, [wideModeType, sidebar.close, sidebar.open, uiPrefs.tocEnabled, hasTocEntries]); + }, [ + wideModeType, + sidebar.close, + sidebar.open, + uiPrefs.tocEnabled, + hasTocEntries, + ]); // Auto-close the sidebar when blocks parse with no TOC entries. Fires // only on blocks/hasTocEntries change (not on sidebar state) so a user @@ -293,7 +416,7 @@ const App: React.FC = () => { useEffect(() => { if (blocks.length === 0) return; if (hasTocEntries) return; - if (sidebar.activeTab === 'toc' && sidebar.isOpen) { + if (sidebar.activeTab === "toc" && sidebar.isOpen) { sidebar.close(); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -301,7 +424,7 @@ const App: React.FC = () => { // Clear diff view when switching away from versions tab useEffect(() => { - if (sidebar.activeTab === 'toc' && isPlanDiffActive) { + if (sidebar.activeTab === "toc" && isPlanDiffActive) { setIsPlanDiffActive(false); } }, [sidebar.activeTab]); @@ -310,87 +433,127 @@ const App: React.FC = () => { useEffect(() => { if (!isPlanDiffActive) return; const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { + if (e.key === "Escape") { setIsPlanDiffActive(false); } }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); }, [isPlanDiffActive]); // Plan diff computation const planDiff = usePlanDiff(markdown, previousPlan, versionInfo); - const linkedDocSidebar = useMemo(() => ({ - ...sidebar, - open: openSidebarTab, - toggleTab: toggleSidebarTab, - }), [ - openSidebarTab, - sidebar.activeTab, - sidebar.close, - sidebar.isOpen, - toggleSidebarTab, - ]); + const linkedDocSidebar = useMemo( + () => ({ + ...sidebar, + open: openSidebarTab, + toggleTab: toggleSidebarTab, + }), + [ + openSidebarTab, + sidebar.activeTab, + sidebar.close, + sidebar.isOpen, + toggleSidebarTab, + ], + ); // Linked document navigation const linkedDocHook = useLinkedDoc({ - markdown, annotations, selectedAnnotationId, globalAttachments, - setMarkdown, setAnnotations, setSelectedAnnotationId, setGlobalAttachments, - viewerRef, sidebar: linkedDocSidebar, sourceFilePath, sourceConverted, + markdown, + annotations, + selectedAnnotationId, + globalAttachments, + setMarkdown, + setAnnotations, + setSelectedAnnotationId, + setGlobalAttachments, + viewerRef, + sidebar: linkedDocSidebar, + sourceFilePath, + sourceConverted, }); // Active document's directory — feeds both click-time popout fetches and // the validator hook so they resolve against the same base. Drifting // these would silently re-introduce the demote-correct-link bug. const activeDocBaseDir = useMemo( - () => linkedDocHook.filepath - ? linkedDocHook.filepath.replace(/\/[^/]+$/, '') - : imageBaseDir?.includes('/') ? imageBaseDir : undefined, + () => + linkedDocHook.filepath + ? linkedDocHook.filepath.replace(/\/[^/]+$/, "") + : imageBaseDir?.includes("/") + ? imageBaseDir + : undefined, [linkedDocHook.filepath, imageBaseDir], ); // Code file popout (read-only syntax-highlighted overlay) const codeFilePopout = useCodeFilePopout({ - buildUrl: useCallback((codePath: string) => { - return activeDocBaseDir - ? `/api/doc?path=${encodeURIComponent(codePath)}&base=${encodeURIComponent(activeDocBaseDir)}` - : `/api/doc?path=${encodeURIComponent(codePath)}`; - }, [activeDocBaseDir]), + buildUrl: useCallback( + (codePath: string) => { + return activeDocBaseDir + ? `/api/doc?path=${encodeURIComponent(codePath)}&base=${encodeURIComponent(activeDocBaseDir)}` + : `/api/doc?path=${encodeURIComponent(codePath)}`; + }, + [activeDocBaseDir], + ), }); // Archive browser const archive = useArchive({ - markdown, viewerRef, linkedDocHook, - setMarkdown, setAnnotations, setSelectedAnnotationId, setSubmitted, + markdown, + viewerRef, + linkedDocHook, + setMarkdown, + setAnnotations, + setSelectedAnnotationId, + setSubmitted, }); - const canUseWideMode = useMemo(() => canUseAnnotateWideMode({ - archiveMode: archive.archiveMode, - isPlanDiffActive, - }), [archive.archiveMode, isPlanDiffActive]); + const canUseWideMode = useMemo( + () => + canUseAnnotateWideMode({ + archiveMode: archive.archiveMode, + isPlanDiffActive, + }), + [archive.archiveMode, isPlanDiffActive], + ); - const enterViewMode = useCallback((type: WideModeType) => { - if (!canUseWideMode) return; - if (wideModeType === null) { - wideModeSnapshotRef.current = { - sidebarIsOpen: sidebar.isOpen, - sidebarTab: sidebar.activeTab, - panelOpen: isPanelOpen, - }; - } - setWideModeType(type); - sidebar.close(); - setIsPanelOpen(false); - }, [canUseWideMode, isPanelOpen, wideModeType, sidebar.activeTab, sidebar.close, sidebar.isOpen]); + const enterViewMode = useCallback( + (type: WideModeType) => { + if (!canUseWideMode) return; + if (wideModeType === null) { + wideModeSnapshotRef.current = { + sidebarIsOpen: sidebar.isOpen, + sidebarTab: sidebar.activeTab, + panelOpen: isPanelOpen, + }; + } + setWideModeType(type); + sidebar.close(); + setIsPanelOpen(false); + }, + [ + canUseWideMode, + isPanelOpen, + wideModeType, + sidebar.activeTab, + sidebar.close, + sidebar.isOpen, + ], + ); - const toggleViewMode = useCallback((type: WideModeType) => { - if (wideModeType === type) { - exitWideMode(); - } else { - enterViewMode(type); - } - }, [enterViewMode, exitWideMode, wideModeType]); + const toggleViewMode = useCallback( + (type: WideModeType) => { + if (wideModeType === type) { + exitWideMode(); + } else { + enterViewMode(type); + } + }, + [enterViewMode, exitWideMode, wideModeType], + ); useEffect(() => { if (!canUseWideMode && wideModeType !== null) { @@ -401,12 +564,12 @@ const App: React.FC = () => { // Markdown file browser (also handles vault dirs via isVault flag) const fileBrowser = useFileBrowser(); const vaultPath = useMemo(() => { - if (!isVaultBrowserEnabled()) return ''; + if (!isVaultBrowserEnabled()) return ""; return getEffectiveVaultPath(getObsidianSettings()); }, [uiPrefs]); const showFilesTab = useMemo( () => !!projectRoot || isFileBrowserEnabled() || isVaultBrowserEnabled(), - [projectRoot, uiPrefs] + [projectRoot, uiPrefs], ); const fileBrowserDirs = useMemo(() => { const projectDirs = projectRoot ? [projectRoot] : []; @@ -427,17 +590,25 @@ const App: React.FC = () => { }, [vaultPath]); useEffect(() => { - if (sidebar.activeTab === 'files' && showFilesTab) { + if (sidebar.activeTab === "files" && showFilesTab) { // Load regular dirs if (fileBrowserDirs.length > 0) { - const regularLoaded = fileBrowser.dirs.filter(d => !d.isVault).map(d => d.path); - const needsRegular = fileBrowserDirs.some(d => !regularLoaded.includes(d)) - || regularLoaded.some(d => !fileBrowserDirs.includes(d)); + const regularLoaded = fileBrowser.dirs + .filter((d) => !d.isVault) + .map((d) => d.path); + const needsRegular = + fileBrowserDirs.some((d) => !regularLoaded.includes(d)) || + regularLoaded.some((d) => !fileBrowserDirs.includes(d)); if (needsRegular) fileBrowser.fetchAll(fileBrowserDirs); } // Load vault dir; addVaultDir atomically replaces any existing vault entry so // switching vault paths never accumulates stale sections - if (vaultPath && !fileBrowser.dirs.find(d => d.isVault && d.path === vaultPath && !d.error)) { + if ( + vaultPath && + !fileBrowser.dirs.find( + (d) => d.isVault && d.path === vaultPath && !d.error, + ) + ) { fileBrowser.addVaultDir(vaultPath); } } @@ -445,42 +616,68 @@ const App: React.FC = () => { // File browser file selection: open via linked doc system // For vault dirs (isVault), use the Obsidian doc endpoint; otherwise use generic /api/doc - const handleFileBrowserSelect = React.useCallback((absolutePath: string, dirPath: string) => { - const dirState = fileBrowser.dirs.find(d => d.path === dirPath); - const buildUrl = dirState?.isVault - ? (path: string) => `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(dirPath)}&path=${encodeURIComponent(path)}` - : (path: string) => `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(dirPath)}`; - linkedDocHook.open(absolutePath, buildUrl, 'files'); - fileBrowser.setActiveFile(absolutePath); - }, [linkedDocHook, fileBrowser]); + const handleFileBrowserSelect = React.useCallback( + (absolutePath: string, dirPath: string) => { + const dirState = fileBrowser.dirs.find((d) => d.path === dirPath); + const buildUrl = dirState?.isVault + ? (path: string) => + `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(dirPath)}&path=${encodeURIComponent(path)}` + : (path: string) => + `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(dirPath)}`; + linkedDocHook.open(absolutePath, buildUrl, "files"); + fileBrowser.setActiveFile(absolutePath); + }, + [linkedDocHook, fileBrowser], + ); // Route linked doc opens through the correct endpoint based on current context - const handleOpenLinkedDoc = React.useCallback((docPath: string) => { - const activeDirState = fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath); - if (activeDirState?.isVault && fileBrowser.activeDirPath) { - linkedDocHook.open(docPath, (path) => - `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(fileBrowser.activeDirPath!)}&path=${encodeURIComponent(path)}` - ); - } else if (fileBrowser.activeFile && fileBrowser.activeDirPath) { - // When viewing a file browser doc, resolve links relative to current file's directory - const baseDir = linkedDocHook.filepath?.replace(/\/[^/]+$/, '') || fileBrowser.activeDirPath; - linkedDocHook.open(docPath, (path) => - `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(baseDir)}` + const handleOpenLinkedDoc = React.useCallback( + (docPath: string) => { + const activeDirState = fileBrowser.dirs.find( + (d) => d.path === fileBrowser.activeDirPath, ); - } else { - // Pass the current file's directory as base for relative path resolution - const baseDir = linkedDocHook.filepath - ? linkedDocHook.filepath.replace(/\/[^/]+$/, '') - : imageBaseDir?.includes('/') ? imageBaseDir : undefined; - if (baseDir) { - linkedDocHook.open(docPath, (path) => - `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(baseDir)}` + if (activeDirState?.isVault && fileBrowser.activeDirPath) { + linkedDocHook.open( + docPath, + (path) => + `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(fileBrowser.activeDirPath!)}&path=${encodeURIComponent(path)}`, + ); + } else if (fileBrowser.activeFile && fileBrowser.activeDirPath) { + // When viewing a file browser doc, resolve links relative to current file's directory + const baseDir = + linkedDocHook.filepath?.replace(/\/[^/]+$/, "") || + fileBrowser.activeDirPath; + linkedDocHook.open( + docPath, + (path) => + `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(baseDir)}`, ); } else { - linkedDocHook.open(docPath); + // Pass the current file's directory as base for relative path resolution + const baseDir = linkedDocHook.filepath + ? linkedDocHook.filepath.replace(/\/[^/]+$/, "") + : imageBaseDir?.includes("/") + ? imageBaseDir + : undefined; + if (baseDir) { + linkedDocHook.open( + docPath, + (path) => + `/api/doc?path=${encodeURIComponent(path)}&base=${encodeURIComponent(baseDir)}`, + ); + } else { + linkedDocHook.open(docPath); + } } - } - }, [fileBrowser.dirs, fileBrowser.activeDirPath, fileBrowser.activeFile, linkedDocHook, imageBaseDir]); + }, + [ + fileBrowser.dirs, + fileBrowser.activeDirPath, + fileBrowser.activeFile, + linkedDocHook, + imageBaseDir, + ], + ); // Wrap linked doc back to also clear file browser active file const handleLinkedDocBack = React.useCallback(() => { @@ -501,11 +698,11 @@ const App: React.FC = () => { // FileBrowser counts: all files under any loaded dir (regular + vault) const fileAnnotationCounts = useMemo(() => { - const allDirPaths = fileBrowser.dirs.map(d => d.path); + const allDirPaths = fileBrowser.dirs.map((d) => d.path); if (allDirPaths.length === 0) return allAnnotationCounts; const counts = new Map(); for (const [fp, count] of allAnnotationCounts) { - if (allDirPaths.some(dir => fp.startsWith(dir + '/'))) { + if (allDirPaths.some((dir) => fp.startsWith(dir + "/"))) { counts.set(fp, count); } } @@ -529,14 +726,16 @@ const App: React.FC = () => { }, [allAnnotationCounts, linkedDocHook.filepath]); // Flash highlight for annotated files in the sidebar - const [highlightedFiles, setHighlightedFiles] = useState | undefined>(); + const [highlightedFiles, setHighlightedFiles] = useState< + Set | undefined + >(); const flashTimerRef = React.useRef>(); const handleFlashAnnotatedFiles = React.useCallback(() => { const filePaths = new Set(allAnnotationCounts.keys()); if (filePaths.size === 0) return; // Open sidebar to the files tab so the flash is visible - if (!sidebar.isOpen || sidebar.activeTab !== 'files') { - openSidebarTab('files'); + if (!sidebar.isOpen || sidebar.activeTab !== "files") { + openSidebarTab("files"); } // Cancel any pending clear from a previous flash if (flashTimerRef.current) clearTimeout(flashTimerRef.current); @@ -544,22 +743,40 @@ const App: React.FC = () => { setHighlightedFiles(undefined); requestAnimationFrame(() => { setHighlightedFiles(filePaths); - flashTimerRef.current = setTimeout(() => setHighlightedFiles(undefined), 1200); + flashTimerRef.current = setTimeout( + () => setHighlightedFiles(undefined), + 1200, + ); }); }, [allAnnotationCounts, openSidebarTab, sidebar, hasFileAnnotations]); // Context-aware back label for linked doc navigation - const backLabel = annotateSource === 'folder' ? 'file list' - : annotateSource === 'file' ? 'file' - : annotateSource === 'message' ? 'message' - : 'plan'; + const backLabel = + annotateSource === "folder" + ? "file list" + : annotateSource === "file" + ? "file" + : annotateSource === "message" + ? "message" + : "plan"; // Track active section for TOC highlighting - const headingCount = useMemo(() => blocks.filter(b => b.type === 'heading').length, [blocks]); - const activeSection = useActiveSection(containerRef, headingCount, scrollViewport); + const headingCount = useMemo( + () => blocks.filter((b) => b.type === "heading").length, + [blocks], + ); + const activeSection = useActiveSection( + containerRef, + headingCount, + scrollViewport, + ); const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations(); - const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations({ enabled: isApiMode }); + const { + externalAnnotations, + updateExternalAnnotation, + deleteExternalAnnotation, + } = useExternalAnnotations({ enabled: isApiMode }); // Drive DOM highlights for SSE-delivered external annotations. Disabled // while a linked doc overlay is open (Viewer DOM is hidden) and while the @@ -578,12 +795,13 @@ const App: React.FC = () => { const allAnnotations = useMemo(() => { if (externalAnnotations.length === 0) return annotations; - const local = annotations.filter(a => { + const local = annotations.filter((a) => { if (!a.source) return true; - return !externalAnnotations.some(ext => - ext.source === a.source && - ext.type === a.type && - ext.originalText === a.originalText + return !externalAnnotations.some( + (ext) => + ext.source === a.source && + ext.type === a.type && + ext.originalText === a.originalText, ); }); @@ -591,17 +809,30 @@ const App: React.FC = () => { }, [annotations, externalAnnotations]); // Plan diff state — memoize filtered annotation lists to avoid new references per render - const diffAnnotations = useMemo(() => allAnnotations.filter(a => !!a.diffContext), [allAnnotations]); - const viewerAnnotations = useMemo(() => allAnnotations.filter(a => !a.diffContext), [allAnnotations]); + const diffAnnotations = useMemo( + () => allAnnotations.filter((a) => !!a.diffContext), + [allAnnotations], + ); + const viewerAnnotations = useMemo( + () => allAnnotations.filter((a) => !a.diffContext), + [allAnnotations], + ); // Any-annotations flag used by Close/Approve/Send guards. Consolidates the // four-term check that was inlined across the annotate-mode header + keyboard paths. const hasAnyAnnotations = useMemo( - () => allAnnotations.length > 0 - || codeAnnotations.length > 0 - || editorAnnotations.length > 0 - || linkedDocHook.docAnnotationCount > 0 - || globalAttachments.length > 0, - [allAnnotations.length, codeAnnotations.length, editorAnnotations.length, linkedDocHook.docAnnotationCount, globalAttachments.length], + () => + allAnnotations.length > 0 || + codeAnnotations.length > 0 || + editorAnnotations.length > 0 || + linkedDocHook.docAnnotationCount > 0 || + globalAttachments.length > 0, + [ + allAnnotations.length, + codeAnnotations.length, + editorAnnotations.length, + linkedDocHook.docAnnotationCount, + globalAttachments.length, + ], ); const feedbackAnnotationCount = allAnnotations.length + @@ -658,20 +889,34 @@ const App: React.FC = () => { }); const handleRestoreDraft = React.useCallback(() => { - const { annotations: restored, codeAnnotations: restoredCode, globalAttachments: restoredGlobal } = restoreDraft(); - if (restored.length > 0 || restoredCode.length > 0 || restoredGlobal.length > 0) { + const { + annotations: restored, + codeAnnotations: restoredCode, + globalAttachments: restoredGlobal, + } = restoreDraft(); + if ( + restored.length > 0 || + restoredCode.length > 0 || + restoredGlobal.length > 0 + ) { setAnnotations(restored); setCodeAnnotations(restoredCode); if (restoredGlobal.length > 0) setGlobalAttachments(restoredGlobal); // Apply highlights to DOM after a tick setTimeout(() => { - viewerRef.current?.applySharedAnnotations(restored.filter(a => !a.diffContext)); + viewerRef.current?.applySharedAnnotations( + restored.filter((a) => !a.diffContext), + ); }, 100); } }, [restoreDraft]); // Fetch available agents for OpenCode (for validation on approve) - const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin); + const { + agents: availableAgents, + validateAgent, + getAgentWarning, + } = useAgents(origin); // Apply shared annotations to DOM after they're loaded useEffect(() => { @@ -680,7 +925,9 @@ const App: React.FC = () => { const timer = setTimeout(() => { // Clear existing highlights first (important when loading new share URL) viewerRef.current?.clearAllHighlights(); - viewerRef.current?.applySharedAnnotations(pendingSharedAnnotations.filter(a => !a.diffContext)); + viewerRef.current?.applySharedAnnotations( + pendingSharedAnnotations.filter((a) => !a.diffContext), + ); clearPendingSharedAnnotations(); // `clearAllHighlights` wiped live external SSE highlights too; // tell the external-highlight bookkeeper to re-apply them. @@ -688,11 +935,15 @@ const App: React.FC = () => { }, 100); return () => clearTimeout(timer); } - }, [pendingSharedAnnotations, clearPendingSharedAnnotations, resetExternalHighlights]); + }, [ + pendingSharedAnnotations, + clearPendingSharedAnnotations, + resetExternalHighlights, + ]); const handleTaterModeChange = useCallback((enabled: boolean) => { setTaterMode(enabled); - storage.setItem('plannotator-tater-mode', String(enabled)); + storage.setItem("plannotator-tater-mode", String(enabled)); }, []); const handleEditorModeChange = (mode: EditorMode) => { @@ -714,91 +965,131 @@ const App: React.FC = () => { if (isLoadingShared) return; // Wait for share check to complete if (isSharedSession) return; // Already loaded from share - fetch('/api/plan') - .then(res => { - if (!res.ok) throw new Error('Not in API mode'); + fetch("/api/plan") + .then((res) => { + if (!res.ok) throw new Error("Not in API mode"); return res.json(); }) - .then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive'; filePath?: string; sourceInfo?: string; sourceConverted?: boolean; gate?: boolean; renderAs?: 'html' | 'markdown'; rawHtml?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string; host?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => { - // Initialize config store with server-provided values (config file > cookie > default) - configStore.init(data.serverConfig); - // gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable - setGitUser(data.serverConfig?.gitUser); - if (data.mode === 'archive') { - // Archive mode: show first archived plan or clear demo content - setMarkdown(data.plan || ''); - if (data.archivePlans) archive.init(data.archivePlans); - archive.fetchPlans(); - setSharingEnabled(false); - sidebar.open('archive'); - } else if (data.renderAs === 'html' && data.rawHtml) { - setRenderAs('html'); - setRawHtml(data.rawHtml); - setMarkdown(''); - } else if (data.mode === 'annotate-folder') { - // Folder annotation mode: clear demo content, let user pick a file - setMarkdown(''); - } else if (data.plan) { - setMarkdown(data.plan); - } - setIsApiMode(true); - if (data.mode === 'annotate' || data.mode === 'annotate-last' || data.mode === 'annotate-folder') { - setAnnotateMode(true); - setGate(data.gate ?? false); - } - if (data.mode === 'annotate-folder') { - sidebar.open('files'); - } - if (data.mode && data.mode !== 'archive') { - setAnnotateSource(data.mode === 'annotate-last' ? 'message' : data.mode === 'annotate-folder' ? 'folder' : 'file'); - } - setSourceInfo(data.sourceInfo ?? undefined); - setSourceConverted(!!data.sourceConverted); - if (data.filePath) { - setImageBaseDir(data.mode === 'annotate-folder' ? data.filePath : data.filePath.replace(/\/[^/]+$/, '')); - if (data.mode === 'annotate') { - setSourceFilePath(data.filePath); + .then( + (data: { + plan: string; + origin?: Origin; + mode?: "annotate" | "annotate-last" | "annotate-folder" | "archive"; + filePath?: string; + sourceInfo?: string; + sourceConverted?: boolean; + gate?: boolean; + renderAs?: "html" | "markdown"; + rawHtml?: string; + sharingEnabled?: boolean; + shareBaseUrl?: string; + pasteApiUrl?: string; + repoInfo?: { display: string; branch?: string; host?: string }; + previousPlan?: string | null; + versionInfo?: { + version: number; + totalVersions: number; + project: string; + }; + archivePlans?: ArchivedPlan[]; + projectRoot?: string; + isWSL?: boolean; + serverConfig?: { displayName?: string; gitUser?: string }; + }) => { + // Initialize config store with server-provided values (config file > cookie > default) + configStore.init(data.serverConfig); + // gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable + setGitUser(data.serverConfig?.gitUser); + if (data.mode === "archive") { + // Archive mode: show first archived plan or clear demo content + setMarkdown(data.plan || ""); + if (data.archivePlans) archive.init(data.archivePlans); + archive.fetchPlans(); + setSharingEnabled(false); + sidebar.open("archive"); + } else if (data.renderAs === "html" && data.rawHtml) { + setRenderAs("html"); + setRawHtml(data.rawHtml); + setMarkdown(""); + } else if (data.mode === "annotate-folder") { + // Folder annotation mode: clear demo content, let user pick a file + setMarkdown(""); + } else if (data.plan) { + setMarkdown(data.plan); } - } - if (data.sharingEnabled !== undefined) { - setSharingEnabled(data.sharingEnabled); - } - if (data.shareBaseUrl) { - setShareBaseUrl(data.shareBaseUrl); - } - if (data.pasteApiUrl) { - setPasteApiUrl(data.pasteApiUrl); - } - if (data.repoInfo) { - setRepoInfo(data.repoInfo); - } - if (data.projectRoot) { - setProjectRoot(data.projectRoot); - } - // Capture plan version history data - if (data.previousPlan !== undefined) { - setPreviousPlan(data.previousPlan); - } - if (data.versionInfo) { - setVersionInfo(data.versionInfo); - } - if (data.toolName) { - setPendingToolName(data.toolName); - } - if (data.origin) { - setOrigin(data.origin); - // For Claude Code, check if user needs to configure permission mode - if (data.origin === 'claude-code' && needsPermissionModeSetup()) { - setShowPermissionModeSetup(true); + setIsApiMode(true); + if ( + data.mode === "annotate" || + data.mode === "annotate-last" || + data.mode === "annotate-folder" + ) { + setAnnotateMode(true); + setGate(data.gate ?? false); } - // Load saved permission mode preference - const savedPermissionMode = getPermissionModeSettings().mode; - setPermissionMode(savedPermissionMode); - } - if (data.isWSL) { - setIsWSL(true); - } - }) + if (data.mode === "annotate-folder") { + sidebar.open("files"); + } + if (data.mode && data.mode !== "archive") { + setAnnotateSource( + data.mode === "annotate-last" + ? "message" + : data.mode === "annotate-folder" + ? "folder" + : "file", + ); + } + setSourceInfo(data.sourceInfo ?? undefined); + setSourceConverted(!!data.sourceConverted); + if (data.filePath) { + setImageBaseDir( + data.mode === "annotate-folder" + ? data.filePath + : data.filePath.replace(/\/[^/]+$/, ""), + ); + if (data.mode === "annotate") { + setSourceFilePath(data.filePath); + } + } + if (data.sharingEnabled !== undefined) { + setSharingEnabled(data.sharingEnabled); + } + if (data.shareBaseUrl) { + setShareBaseUrl(data.shareBaseUrl); + } + if (data.pasteApiUrl) { + setPasteApiUrl(data.pasteApiUrl); + } + if (data.repoInfo) { + setRepoInfo(data.repoInfo); + } + if (data.projectRoot) { + setProjectRoot(data.projectRoot); + } + // Capture plan version history data + if (data.previousPlan !== undefined) { + setPreviousPlan(data.previousPlan); + } + if (data.versionInfo) { + setVersionInfo(data.versionInfo); + } + if (data.toolName) { + setPendingToolName(data.toolName); + } + if (data.origin) { + setOrigin(data.origin); + // For Claude Code, check if user needs to configure permission mode + if (data.origin === "claude-code" && needsPermissionModeSetup()) { + setShowPermissionModeSetup(true); + } + // Load saved permission mode preference + const savedPermissionMode = getPermissionModeSettings().mode; + setPermissionMode(savedPermissionMode); + } + if (data.isWSL) { + setIsWSL(true); + } + }, + ) .catch(() => { // Not in API mode - use default content setIsApiMode(false); @@ -818,7 +1109,14 @@ const App: React.FC = () => { }, [markdown]); useEffect(() => { - if (!isApiMode || !markdown || isSharedSession || annotateMode || archive.archiveMode) return; + if ( + !isApiMode || + !markdown || + isSharedSession || + annotateMode || + archive.archiveMode + ) + return; if (autoSaveAttempted.current) return; const body: { obsidian?: object; bear?: object; octarine?: object } = {}; @@ -830,12 +1128,17 @@ const App: React.FC = () => { if (vaultPath) { body.obsidian = { vaultPath, - folder: obsSettings.folder || 'plannotator', + folder: obsSettings.folder || "plannotator", plan: markdown, - ...(obsSettings.filenameFormat && { filenameFormat: obsSettings.filenameFormat }), - ...(obsSettings.filenameSeparator && obsSettings.filenameSeparator !== 'space' && { filenameSeparator: obsSettings.filenameSeparator }), + ...(obsSettings.filenameFormat && { + filenameFormat: obsSettings.filenameFormat, + }), + ...(obsSettings.filenameSeparator && + obsSettings.filenameSeparator !== "space" && { + filenameSeparator: obsSettings.filenameSeparator, + }), }; - targets.push('Obsidian'); + targets.push("Obsidian"); } } @@ -846,7 +1149,7 @@ const App: React.FC = () => { customTags: bearSettings.customTags, tagPosition: bearSettings.tagPosition, }; - targets.push('Bear'); + targets.push("Bear"); } const octSettings = getOctarineSettings(); @@ -854,40 +1157,46 @@ const App: React.FC = () => { body.octarine = { plan: markdown, workspace: octSettings.workspace, - folder: octSettings.folder || 'plannotator', + folder: octSettings.folder || "plannotator", }; - targets.push('Octarine'); + targets.push("Octarine"); } if (targets.length === 0) return; autoSaveAttempted.current = true; - const autoSavePromise = fetch('/api/save-notes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const autoSavePromise = fetch("/api/save-notes", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) - .then(res => res.json()) - .then(data => { + .then((res) => res.json()) + .then((data) => { const results: NoteAutoSaveResults = { - ...(body.obsidian ? { obsidian: Boolean(data.results?.obsidian?.success) } : {}), + ...(body.obsidian + ? { obsidian: Boolean(data.results?.obsidian?.success) } + : {}), ...(body.bear ? { bear: Boolean(data.results?.bear?.success) } : {}), - ...(body.octarine ? { octarine: Boolean(data.results?.octarine?.success) } : {}), + ...(body.octarine + ? { octarine: Boolean(data.results?.octarine?.success) } + : {}), }; autoSaveResultsRef.current = results; - const failed = targets.filter(t => !data.results?.[t.toLowerCase()]?.success); + const failed = targets.filter( + (t) => !data.results?.[t.toLowerCase()]?.success, + ); if (failed.length === 0) { - toast.success(`Auto-saved to ${targets.join(' & ')}`); + toast.success(`Auto-saved to ${targets.join(" & ")}`); } else { - toast.error(`Auto-save failed for ${failed.join(' & ')}`); + toast.error(`Auto-save failed for ${failed.join(" & ")}`); } return results; }) .catch(() => { autoSaveResultsRef.current = {}; - toast.error('Auto-save failed'); + toast.error("Auto-save failed"); return {}; }); autoSavePromiseRef.current = autoSavePromise; @@ -900,12 +1209,15 @@ const App: React.FC = () => { if (!items) return; for (const item of items) { - if (item.type.startsWith('image/')) { + if (item.type.startsWith("image/")) { e.preventDefault(); const file = item.getAsFile(); if (file) { // Derive name before showing annotator so user sees it immediately - const initialName = deriveImageName(file.name, globalAttachments.map(g => g.name)); + const initialName = deriveImageName( + file.name, + globalAttachments.map((g) => g.name), + ); const blobUrl = URL.createObjectURL(file); setPendingPasteImage({ file, blobUrl, initialName }); } @@ -914,25 +1226,32 @@ const App: React.FC = () => { } }; - document.addEventListener('paste', handlePaste); - return () => document.removeEventListener('paste', handlePaste); + document.addEventListener("paste", handlePaste); + return () => document.removeEventListener("paste", handlePaste); }, [globalAttachments]); // Handle paste annotator accept — name comes from ImageAnnotator - const handlePasteAnnotatorAccept = async (blob: Blob, hasDrawings: boolean, name: string) => { + const handlePasteAnnotatorAccept = async ( + blob: Blob, + hasDrawings: boolean, + name: string, + ) => { if (!pendingPasteImage) return; try { const formData = new FormData(); const fileToUpload = hasDrawings - ? new File([blob], 'annotated.png', { type: 'image/png' }) + ? new File([blob], "annotated.png", { type: "image/png" }) : pendingPasteImage.file; - formData.append('file', fileToUpload); + formData.append("file", fileToUpload); - const res = await fetch('/api/upload', { method: 'POST', body: formData }); + const res = await fetch("/api/upload", { + method: "POST", + body: formData, + }); if (res.ok) { const data = await res.json(); - setGlobalAttachments(prev => [...prev, { path: data.path, name }]); + setGlobalAttachments((prev) => [...prev, { path: data.path, name }]); } } catch { // Upload failed silently @@ -957,17 +1276,23 @@ const App: React.FC = () => { const bearSettings = getBearSettings(); const octarineSettings = getOctarineSettings(); const planSaveSettings = getPlanSaveSettings(); - const autoSaveResults = bearSettings.autoSave && autoSavePromiseRef.current - ? await autoSavePromiseRef.current - : autoSaveResultsRef.current; + const autoSaveResults = + bearSettings.autoSave && autoSavePromiseRef.current + ? await autoSavePromiseRef.current + : autoSaveResultsRef.current; + const effectiveMode = override.permissionMode ?? permissionMode; const shouldUseNativeClear = - origin === 'claude-code' && - pendingToolName === 'ExitPlanMode' && - (override.deferToNativeForClear || (override.permissionMode ?? permissionMode) === 'bypassPermissionsClearReminder'); + origin === "claude-code" && + pendingToolName === "ExitPlanMode" && + (override.deferToNativeForClear || + effectiveMode === "bypassPermissionsClearReminder" || + effectiveMode === "deferNative"); if (shouldUseNativeClear) { try { - const response = await fetch('/api/enable-clear-context', { method: 'POST' }); + const response = await fetch("/api/enable-clear-context", { + method: "POST", + }); if (response.ok) setShowClearContextBanner(false); } catch { setShowClearContextBanner(true); @@ -988,16 +1313,24 @@ const App: React.FC = () => { if (obsidianSettings.enabled && effectiveVaultPath) { body.obsidian = { vaultPath: effectiveVaultPath, - folder: obsidianSettings.folder || 'plannotator', + folder: obsidianSettings.folder || "plannotator", plan: markdown, - ...(obsidianSettings.filenameFormat && { filenameFormat: obsidianSettings.filenameFormat }), - ...(obsidianSettings.filenameSeparator && obsidianSettings.filenameSeparator !== 'space' && { filenameSeparator: obsidianSettings.filenameSeparator }), + ...(obsidianSettings.filenameFormat && { + filenameFormat: obsidianSettings.filenameFormat, + }), + ...(obsidianSettings.filenameSeparator && + obsidianSettings.filenameSeparator !== "space" && { + filenameSeparator: obsidianSettings.filenameSeparator, + }), }; } // Bear creates a new note each time, so don't send it again on approve // if the arrival auto-save already succeeded. - if (bearSettings.enabled && !(bearSettings.autoSave && autoSaveResults.bear)) { + if ( + bearSettings.enabled && + !(bearSettings.autoSave && autoSaveResults.bear) + ) { body.bear = { plan: markdown, customTags: bearSettings.customTags, @@ -1009,24 +1342,30 @@ const App: React.FC = () => { body.octarine = { plan: markdown, workspace: octarineSettings.workspace, - folder: octarineSettings.folder || 'plannotator', + folder: octarineSettings.folder || "plannotator", }; } // Include annotations as feedback if any exist (for OpenCode "approve with notes") - const hasDocAnnotations = Array.from(linkedDocHook.getDocAnnotations().values()).some( - (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 - ); - if (allAnnotations.length > 0 || codeAnnotations.length > 0 || globalAttachments.length > 0 || hasDocAnnotations || editorAnnotations.length > 0) { + const hasDocAnnotations = Array.from( + linkedDocHook.getDocAnnotations().values(), + ).some((d) => d.annotations.length > 0 || d.globalAttachments.length > 0); + if ( + allAnnotations.length > 0 || + codeAnnotations.length > 0 || + globalAttachments.length > 0 || + hasDocAnnotations || + editorAnnotations.length > 0 + ) { body.feedback = annotationsOutput; } - await fetch('/api/approve', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + await fetch("/api/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - setSubmitted('approved'); + setSubmitted("approved"); } catch { setIsSubmitting(false); } @@ -1036,70 +1375,86 @@ const App: React.FC = () => { setIsSubmitting(true); try { const planSaveSettings = getPlanSaveSettings(); - await fetch('/api/deny', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + await fetch("/api/deny", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ feedback: annotationsOutput, planSave: { enabled: planSaveSettings.enabled, - ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), + ...(planSaveSettings.customPath && { + customPath: planSaveSettings.customPath, + }), }, - }) + }), }); - setSubmitted('denied'); + setSubmitted("denied"); } catch { setIsSubmitting(false); } }; - const approveWithClaudeCodeWarning = useCallback((override: ApprovalOverride = {}) => { - setPendingApprovalOverride(override); - if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { - setShowClaudeCodeWarning(true); - return; - } - handleApprove(override); - }, [allAnnotations.length, codeAnnotations.length, origin, handleApprove]); + const approveWithClaudeCodeWarning = useCallback( + (override: ApprovalOverride = {}) => { + setPendingApprovalOverride(override); + if ( + origin === "claude-code" && + (allAnnotations.length > 0 || codeAnnotations.length > 0) + ) { + setShowClaudeCodeWarning(true); + return; + } + handleApprove(override); + }, + [allAnnotations.length, codeAnnotations.length, origin, handleApprove], + ); const claudeCodeExtraEntries = useMemo(() => { - if (origin !== 'claude-code') return []; - if (pendingToolName === 'ExitPlanMode') { - return [{ - id: 'approve-bypass-native-clear', - label: 'Approve + Bypass + Clear Context (native)', - description: "Defers to Claude Code's native plan-accept dialog so it can clear context and set bypass permissions.", - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - deferToNativeForClear: true, - }), - }]; + if (origin !== "claude-code") return []; + if (pendingToolName === "ExitPlanMode") { + return [ + { + id: "approve-bypass-native-clear", + label: "Approve + Bypass + Clear Context (native)", + description: + "Defers to Claude Code's native plan-accept dialog so it can clear context and set bypass permissions.", + onSelect: () => + approveWithClaudeCodeWarning({ + permissionMode: "bypassPermissions", + deferToNativeForClear: true, + }), + }, + ]; } - return [{ - id: 'approve-bypass-clear-reminder', - label: 'Approve + Bypass + /clear Reminder', - description: 'Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly outside plan acceptance.', - onSelect: () => approveWithClaudeCodeWarning({ - permissionMode: 'bypassPermissions', - clearContextNudge: true, - }), - }]; + return [ + { + id: "approve-bypass-clear-reminder", + label: "Approve + Bypass + /clear Reminder", + description: + "Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly outside plan acceptance.", + onSelect: () => + approveWithClaudeCodeWarning({ + permissionMode: "bypassPermissions", + clearContextNudge: true, + }), + }, + ]; }, [approveWithClaudeCodeWarning, origin, pendingToolName]); // Annotate mode handler — sends feedback via /api/feedback const handleAnnotateFeedback = async () => { setIsSubmitting(true); try { - await fetch('/api/feedback', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + await fetch("/api/feedback", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ feedback: annotationsOutput, annotations: allAnnotations, codeAnnotations, }), }); - setSubmitted('denied'); // reuse 'denied' state for "feedback sent" overlay + setSubmitted("denied"); // reuse 'denied' state for "feedback sent" overlay } catch { setIsSubmitting(false); } @@ -1109,8 +1464,8 @@ const App: React.FC = () => { const handleAnnotateApprove = async () => { setIsSubmitting(true); try { - await fetch('/api/approve', { method: 'POST' }); - setSubmitted('approved'); + await fetch("/api/approve", { method: "POST" }); + setSubmitted("approved"); } catch { setIsSubmitting(false); } @@ -1120,11 +1475,11 @@ const App: React.FC = () => { const handleAnnotateExit = useCallback(async () => { setIsExiting(true); try { - const res = await fetch('/api/exit', { method: 'POST' }); + const res = await fetch("/api/exit", { method: "POST" }); if (res.ok) { - setSubmitted('exited'); + setSubmitted("exited"); } else { - throw new Error('Failed to exit'); + throw new Error("Failed to exit"); } } catch { setIsExiting(false); @@ -1135,15 +1490,24 @@ const App: React.FC = () => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Only handle Cmd/Ctrl+Enter - if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return; + if (e.key !== "Enter" || !(e.metaKey || e.ctrlKey)) return; // Don't intercept if typing in an input/textarea const tag = (e.target as HTMLElement)?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA') return; + if (tag === "INPUT" || tag === "TEXTAREA") return; // Don't intercept if any modal is open - if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning || - showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; + if ( + showExport || + showImport || + showFeedbackPrompt || + showClaudeCodeWarning || + showExitWarning || + showAgentWarning || + showPermissionModeSetup || + pendingPasteImage + ) + return; // Don't intercept if already submitted, submitting, or exiting if (submitted || isSubmitting || isExiting) return; @@ -1170,11 +1534,16 @@ const App: React.FC = () => { // No annotations → Approve, otherwise → Send Feedback const docAnnotations = linkedDocHook.getDocAnnotations(); const hasDocAnnotations = Array.from(docAnnotations.values()).some( - (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 + (d) => d.annotations.length > 0 || d.globalAttachments.length > 0, ); - if (allAnnotations.length === 0 && codeAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { + if ( + allAnnotations.length === 0 && + codeAnnotations.length === 0 && + editorAnnotations.length === 0 && + !hasDocAnnotations + ) { // Check if agent exists for OpenCode users - if (origin === 'opencode') { + if (origin === "opencode") { const warning = getAgentWarning(); if (warning) { setAgentWarningMessage(warning); @@ -1188,18 +1557,34 @@ const App: React.FC = () => { } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); }, [ - showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, - showPermissionModeSetup, pendingPasteImage, - submitted, isSubmitting, isExiting, isApiMode, linkedDocHook.isActive, annotations.length, codeAnnotations.length, externalAnnotations.length, annotateMode, - gate, hasAnyAnnotations, - origin, getAgentWarning, + showExport, + showImport, + showFeedbackPrompt, + showClaudeCodeWarning, + showExitWarning, + showAgentWarning, + showPermissionModeSetup, + pendingPasteImage, + submitted, + isSubmitting, + isExiting, + isApiMode, + linkedDocHook.isActive, + annotations.length, + codeAnnotations.length, + externalAnnotations.length, + annotateMode, + gate, + hasAnyAnnotations, + origin, + getAgentWarning, ]); const handleAddAnnotation = (ann: Annotation) => { - setAnnotations(prev => [...prev, ann]); + setAnnotations((prev) => [...prev, ann]); setSelectedAnnotationId(ann.id); setSelectedCodeAnnotationId(null); if (wideModeType === null) { @@ -1208,60 +1593,77 @@ const App: React.FC = () => { }; // Keep selection behavior explicit across mobile/wide-mode transitions. - const handleSelectAnnotation = React.useCallback((id: string | null) => { - setSelectedAnnotationId(id); - if (id) setSelectedCodeAnnotationId(null); - if (id && isMobile && wideModeType === null) setIsPanelOpen(true); - }, [isMobile, wideModeType]); - - const handleAddCodeAnnotation = React.useCallback((input: CodeFileAnnotationInput) => { - const annotation: CodeAnnotation = { - id: generateId('code-ann'), - type: 'comment', - scope: 'line', - filePath: input.filePath, - lineStart: input.lineStart, - lineEnd: input.lineEnd, - side: 'new', - text: input.text, - images: input.images, - originalCode: input.originalCode, - createdAt: Date.now(), - author: configStore.get('displayName') || undefined, - }; - setCodeAnnotations(prev => [...prev, annotation]); - setSelectedAnnotationId(null); - setSelectedCodeAnnotationId(annotation.id); - if (wideModeType === null) { - setIsPanelOpen(true); - } - }, [wideModeType]); + const handleSelectAnnotation = React.useCallback( + (id: string | null) => { + setSelectedAnnotationId(id); + if (id) setSelectedCodeAnnotationId(null); + if (id && isMobile && wideModeType === null) setIsPanelOpen(true); + }, + [isMobile, wideModeType], + ); + + const handleAddCodeAnnotation = React.useCallback( + (input: CodeFileAnnotationInput) => { + const annotation: CodeAnnotation = { + id: generateId("code-ann"), + type: "comment", + scope: "line", + filePath: input.filePath, + lineStart: input.lineStart, + lineEnd: input.lineEnd, + side: "new", + text: input.text, + images: input.images, + originalCode: input.originalCode, + createdAt: Date.now(), + author: configStore.get("displayName") || undefined, + }; + setCodeAnnotations((prev) => [...prev, annotation]); + setSelectedAnnotationId(null); + setSelectedCodeAnnotationId(annotation.id); + if (wideModeType === null) { + setIsPanelOpen(true); + } + }, + [wideModeType], + ); // The code popout is full-viewport modal — the annotation panel is behind it. // This handler only fires when the popout is closed (sidebar visible), so // reopening the file via codeFilePopout.open() is the correct behavior. - const handleSelectCodeAnnotation = React.useCallback((id: string) => { - const annotation = codeAnnotations.find(a => a.id === id); - if (!annotation) return; - setSelectedAnnotationId(null); - setSelectedCodeAnnotationId(id); - codeFilePopout.open(annotation.filePath); - if (isMobile && wideModeType === null) setIsPanelOpen(true); - }, [codeAnnotations, codeFilePopout.open, isMobile, wideModeType]); - - const handleDeleteCodeAnnotation = React.useCallback((id: string) => { - setCodeAnnotations(prev => prev.filter(a => a.id !== id)); - if (selectedCodeAnnotationId === id) setSelectedCodeAnnotationId(null); - }, [selectedCodeAnnotationId]); - - const handleEditCodeAnnotation = React.useCallback((id: string, updates: Partial) => { - setCodeAnnotations(prev => prev.map(a => a.id === id ? { ...a, ...updates } : a)); - }, []); + const handleSelectCodeAnnotation = React.useCallback( + (id: string) => { + const annotation = codeAnnotations.find((a) => a.id === id); + if (!annotation) return; + setSelectedAnnotationId(null); + setSelectedCodeAnnotationId(id); + codeFilePopout.open(annotation.filePath); + if (isMobile && wideModeType === null) setIsPanelOpen(true); + }, + [codeAnnotations, codeFilePopout.open, isMobile, wideModeType], + ); + + const handleDeleteCodeAnnotation = React.useCallback( + (id: string) => { + setCodeAnnotations((prev) => prev.filter((a) => a.id !== id)); + if (selectedCodeAnnotationId === id) setSelectedCodeAnnotationId(null); + }, + [selectedCodeAnnotationId], + ); + + const handleEditCodeAnnotation = React.useCallback( + (id: string, updates: Partial) => { + setCodeAnnotations((prev) => + prev.map((a) => (a.id === id ? { ...a, ...updates } : a)), + ); + }, + [], + ); // Core annotation removal — highlight cleanup + state filter + selection clear const removeAnnotation = (id: string) => { viewerRef.current?.removeHighlight(id); - setAnnotations(prev => prev.filter(a => a.id !== id)); + setAnnotations((prev) => prev.filter((a) => a.id !== id)); if (selectedAnnotationId === id) setSelectedAnnotationId(null); }; @@ -1274,17 +1676,17 @@ const App: React.FC = () => { }); const handleDeleteAnnotation = (id: string) => { - const ann = allAnnotations.find(a => a.id === id); + const ann = allAnnotations.find((a) => a.id === id); // External annotations (live in SSE hook) route to the SSE hook, not local state. // Check membership by ID — source alone is insufficient because share-imported // and draft-restored annotations also carry source but live in local state. - if (ann?.source && externalAnnotations.some(e => e.id === id)) { + if (ann?.source && externalAnnotations.some((e) => e.id === id)) { deleteExternalAnnotation(id); if (selectedAnnotationId === id) setSelectedAnnotationId(null); return; } // If this is a checkbox annotation, revert the visual override - if (id.startsWith('ann-checkbox-')) { + if (id.startsWith("ann-checkbox-")) { if (ann) { checkbox.revertOverride(ann.blockId); } @@ -1293,34 +1695,40 @@ const App: React.FC = () => { }; const handleEditAnnotation = (id: string, updates: Partial) => { - const ann = allAnnotations.find(a => a.id === id); - if (ann?.source && externalAnnotations.some(e => e.id === id)) { + const ann = allAnnotations.find((a) => a.id === id); + if (ann?.source && externalAnnotations.some((e) => e.id === id)) { updateExternalAnnotation(id, updates); return; } - setAnnotations(prev => prev.map(a => - a.id === id ? { ...a, ...updates } : a - )); + setAnnotations((prev) => + prev.map((a) => (a.id === id ? { ...a, ...updates } : a)), + ); }; - const handleIdentityChange = useCallback((oldIdentity: string, newIdentity: string) => { - setAnnotations(prev => prev.map(ann => - ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann - )); - setCodeAnnotations(prev => prev.map(ann => - ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann - )); - }, []); + const handleIdentityChange = useCallback( + (oldIdentity: string, newIdentity: string) => { + setAnnotations((prev) => + prev.map((ann) => + ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann, + ), + ); + setCodeAnnotations((prev) => + prev.map((ann) => + ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann, + ), + ); + }, + [], + ); const handleAddGlobalAttachment = (image: ImageAttachment) => { - setGlobalAttachments(prev => [...prev, image]); + setGlobalAttachments((prev) => [...prev, image]); }; const handleRemoveGlobalAttachment = (path: string) => { - setGlobalAttachments(prev => prev.filter(p => p.path !== path)); + setGlobalAttachments((prev) => prev.filter((p) => p.path !== path)); }; - const handleTocNavigate = (blockId: string) => { // Navigation handled by TableOfContents component // This is just a placeholder for future custom logic @@ -1329,20 +1737,26 @@ const App: React.FC = () => { const annotationsOutput = useMemo(() => { const docAnnotations = linkedDocHook.getDocAnnotations(); const hasDocAnnotations = Array.from(docAnnotations.values()).some( - (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 + (d) => d.annotations.length > 0 || d.globalAttachments.length > 0, ); - const hasPlanAnnotations = allAnnotations.length > 0 || globalAttachments.length > 0; + const hasPlanAnnotations = + allAnnotations.length > 0 || globalAttachments.length > 0; const hasEditorAnnotations = editorAnnotations.length > 0; const hasCodeAnnotations = codeAnnotations.length > 0; - if (!hasPlanAnnotations && !hasDocAnnotations && !hasEditorAnnotations && !hasCodeAnnotations) { - return 'User reviewed the document and has no feedback.'; + if ( + !hasPlanAnnotations && + !hasDocAnnotations && + !hasEditorAnnotations && + !hasCodeAnnotations + ) { + return "User reviewed the document and has no feedback."; } // Derive the conversion flag for the currently-displayed document: // when viewing a linked doc, use that doc's isConverted; otherwise use the root flag. const activeConverted = linkedDocHook.isActive - ? (docAnnotations.get(linkedDocHook.filepath ?? '')?.isConverted ?? false) + ? (docAnnotations.get(linkedDocHook.filepath ?? "")?.isConverted ?? false) : sourceConverted; let output = hasPlanAnnotations @@ -1350,19 +1764,30 @@ const App: React.FC = () => { blocks, allAnnotations, globalAttachments, - annotateSource === 'message' ? 'Message Feedback' : annotateSource === 'folder' ? 'Folder Feedback' : annotateSource === 'file' ? 'File Feedback' : 'Plan Feedback', - annotateSource ?? 'plan', + annotateSource === "message" + ? "Message Feedback" + : annotateSource === "folder" + ? "Folder Feedback" + : annotateSource === "file" + ? "File Feedback" + : "Plan Feedback", + annotateSource ?? "plan", { sourceConverted: activeConverted }, ) - : ''; + : ""; if (hasDocAnnotations) { // Parse blocks for each linked doc's cached markdown so the exporter // can attach source line numbers per annotation. - const enriched: Map = new Map(docAnnotations); + const enriched: Map = new Map( + docAnnotations, + ); for (const [filepath, entry] of enriched) { if (entry.markdown) { - enriched.set(filepath, { ...entry, blocks: parseMarkdownToBlocks(entry.markdown) }); + enriched.set(filepath, { + ...entry, + blocks: parseMarkdownToBlocks(entry.markdown), + }); } } output += exportLinkedDocAnnotations(enriched); @@ -1377,7 +1802,18 @@ const App: React.FC = () => { } return output; - }, [blocks, allAnnotations, globalAttachments, linkedDocHook.getDocAnnotations, editorAnnotations, codeAnnotations, sourceConverted, annotateSource, linkedDocHook.isActive, linkedDocHook.filepath]); + }, [ + blocks, + allAnnotations, + globalAttachments, + linkedDocHook.getDocAnnotations, + editorAnnotations, + codeAnnotations, + sourceConverted, + annotateSource, + linkedDocHook.isActive, + linkedDocHook.filepath, + ]); // Bot callback config — read once from URL search params (?cb=&ct=) // TODO: bot callbacks post shareUrl which doesn't include code-file annotations. @@ -1385,56 +1821,77 @@ const App: React.FC = () => { // Fix: either disable callbacks when codeAnnotations exist, or include annotationsOutput in the payload. const callbackConfig = React.useMemo(() => getCallbackConfig(), []); - const callCallback = React.useCallback(async (action: CallbackAction) => { - if (!callbackConfig || isSubmitting || (!shareUrl && !shortShareUrl)) return; - setIsSubmitting(true); - try { - const result = await executeCallback(action, callbackConfig, shortShareUrl || shareUrl); - if (result) { - if (result.type === 'success') { - toast.success(result.message); - setSubmitted(action === CallbackAction.Approve ? 'approved' : 'denied'); - } else { - toast.error(result.message); + const callCallback = React.useCallback( + async (action: CallbackAction) => { + if (!callbackConfig || isSubmitting || (!shareUrl && !shortShareUrl)) + return; + setIsSubmitting(true); + try { + const result = await executeCallback( + action, + callbackConfig, + shortShareUrl || shareUrl, + ); + if (result) { + if (result.type === "success") { + toast.success(result.message); + setSubmitted( + action === CallbackAction.Approve ? "approved" : "denied", + ); + } else { + toast.error(result.message); + } } + } finally { + setIsSubmitting(false); } - } finally { - setIsSubmitting(false); - } - }, [callbackConfig, isSubmitting, shareUrl, shortShareUrl]); + }, + [callbackConfig, isSubmitting, shareUrl, shortShareUrl], + ); - const handleCallbackApprove = React.useCallback(() => callCallback(CallbackAction.Approve), [callCallback]); - const handleCallbackFeedback = React.useCallback(() => callCallback(CallbackAction.Feedback), [callCallback]); + const handleCallbackApprove = React.useCallback( + () => callCallback(CallbackAction.Approve), + [callCallback], + ); + const handleCallbackFeedback = React.useCallback( + () => callCallback(CallbackAction.Feedback), + [callCallback], + ); // Quick-save handlers for export dropdown and keyboard shortcut const handleDownloadAnnotations = () => { - const blob = new Blob([annotationsOutput], { type: 'text/plain' }); + const blob = new Blob([annotationsOutput], { type: "text/plain" }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); + const a = document.createElement("a"); a.href = url; - a.download = 'annotations.md'; + a.download = "annotations.md"; a.click(); URL.revokeObjectURL(url); - toast.success('Downloaded annotations'); + toast.success("Downloaded annotations"); }; - const handleQuickSaveToNotes = async (target: 'obsidian' | 'bear' | 'octarine') => { + const handleQuickSaveToNotes = async ( + target: "obsidian" | "bear" | "octarine", + ) => { const body: { obsidian?: object; bear?: object; octarine?: object } = {}; - if (target === 'obsidian') { + if (target === "obsidian") { const s = getObsidianSettings(); const vaultPath = getEffectiveVaultPath(s); if (vaultPath) { body.obsidian = { vaultPath, - folder: s.folder || 'plannotator', + folder: s.folder || "plannotator", plan: markdown, ...(s.filenameFormat && { filenameFormat: s.filenameFormat }), - ...(s.filenameSeparator && s.filenameSeparator !== 'space' && { filenameSeparator: s.filenameSeparator }), + ...(s.filenameSeparator && + s.filenameSeparator !== "space" && { + filenameSeparator: s.filenameSeparator, + }), }; } } - if (target === 'bear') { + if (target === "bear") { const bs = getBearSettings(); body.bear = { plan: markdown, @@ -1442,20 +1899,25 @@ const App: React.FC = () => { tagPosition: bs.tagPosition, }; } - if (target === 'octarine') { + if (target === "octarine") { const os = getOctarineSettings(); body.octarine = { plan: markdown, workspace: os.workspace, - folder: os.folder || 'plannotator', + folder: os.folder || "plannotator", }; } - const targetName = target === 'obsidian' ? 'Obsidian' : target === 'bear' ? 'Bear' : 'Octarine'; + const targetName = + target === "obsidian" + ? "Obsidian" + : target === "bear" + ? "Bear" + : "Octarine"; try { - const res = await fetch('/api/save-notes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/save-notes", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await res.json(); @@ -1463,10 +1925,10 @@ const App: React.FC = () => { if (result?.success) { toast.success(`Saved to ${targetName}`); } else { - toast.error(result?.error || 'Save failed'); + toast.error(result?.error || "Save failed"); } } catch { - toast.error('Save failed'); + toast.error("Save failed"); } }; @@ -1478,9 +1940,9 @@ const App: React.FC = () => { const payload = buildPlanAgentInstructions(window.location.origin); try { await navigator.clipboard.writeText(payload); - toast.success('Agent instructions copied'); + toast.success("Agent instructions copied"); } catch { - toast.error('Failed to copy'); + toast.error("Failed to copy"); } }; @@ -1489,22 +1951,30 @@ const App: React.FC = () => { if (!url) return; try { await navigator.clipboard.writeText(url); - toast.success('Share link copied'); + toast.success("Share link copied"); } catch { - toast.error('Failed to copy'); + toast.error("Failed to copy"); } }; // Cmd/Ctrl+S keyboard shortcut — save to default notes app useEffect(() => { const handleSaveShortcut = (e: KeyboardEvent) => { - if (e.key !== 's' || !(e.metaKey || e.ctrlKey)) return; + if (e.key !== "s" || !(e.metaKey || e.ctrlKey)) return; const tag = (e.target as HTMLElement)?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA') return; - - if (showExport || showFeedbackPrompt || showClaudeCodeWarning || - showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; + if (tag === "INPUT" || tag === "TEXTAREA") return; + + if ( + showExport || + showFeedbackPrompt || + showClaudeCodeWarning || + showExitWarning || + showAgentWarning || + showPermissionModeSetup || + pendingPasteImage + ) + return; if (submitted || !isApiMode) return; @@ -1515,38 +1985,54 @@ const App: React.FC = () => { const bearOk = getBearSettings().enabled; const octOk = isOctarineConfigured(); - if (defaultApp === 'download') { + if (defaultApp === "download") { handleDownloadAnnotations(); - } else if (defaultApp === 'obsidian' && obsOk) { - handleQuickSaveToNotes('obsidian'); - } else if (defaultApp === 'bear' && bearOk) { - handleQuickSaveToNotes('bear'); - } else if (defaultApp === 'octarine' && octOk) { - handleQuickSaveToNotes('octarine'); + } else if (defaultApp === "obsidian" && obsOk) { + handleQuickSaveToNotes("obsidian"); + } else if (defaultApp === "bear" && bearOk) { + handleQuickSaveToNotes("bear"); + } else if (defaultApp === "octarine" && octOk) { + handleQuickSaveToNotes("octarine"); } else { - setInitialExportTab('notes'); + setInitialExportTab("notes"); setShowExport(true); } }; - window.addEventListener('keydown', handleSaveShortcut); - return () => window.removeEventListener('keydown', handleSaveShortcut); + window.addEventListener("keydown", handleSaveShortcut); + return () => window.removeEventListener("keydown", handleSaveShortcut); }, [ - showExport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, - showPermissionModeSetup, pendingPasteImage, - submitted, isApiMode, markdown, annotationsOutput, + showExport, + showFeedbackPrompt, + showClaudeCodeWarning, + showExitWarning, + showAgentWarning, + showPermissionModeSetup, + pendingPasteImage, + submitted, + isApiMode, + markdown, + annotationsOutput, ]); // Cmd/Ctrl+P keyboard shortcut — print plan useEffect(() => { const handlePrintShortcut = (e: KeyboardEvent) => { - if (e.key !== 'p' || !(e.metaKey || e.ctrlKey)) return; + if (e.key !== "p" || !(e.metaKey || e.ctrlKey)) return; const tag = (e.target as HTMLElement)?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA') return; - - if (showExport || showFeedbackPrompt || showClaudeCodeWarning || - showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; + if (tag === "INPUT" || tag === "TEXTAREA") return; + + if ( + showExport || + showFeedbackPrompt || + showClaudeCodeWarning || + showExitWarning || + showAgentWarning || + showPermissionModeSetup || + pendingPasteImage + ) + return; if (submitted) return; @@ -1554,11 +2040,17 @@ const App: React.FC = () => { window.print(); }; - window.addEventListener('keydown', handlePrintShortcut); - return () => window.removeEventListener('keydown', handlePrintShortcut); + window.addEventListener("keydown", handlePrintShortcut); + return () => window.removeEventListener("keydown", handlePrintShortcut); }, [ - showExport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, - showPermissionModeSetup, pendingPasteImage, submitted, + showExport, + showFeedbackPrompt, + showClaudeCodeWarning, + showExitWarning, + showAgentWarning, + showPermissionModeSetup, + pendingPasteImage, + submitted, ]); const agentName = useMemo(() => getAgentName(origin), [origin]); @@ -1595,7 +2087,7 @@ const App: React.FC = () => { const handleHeaderAnnotateExit = useCallback(() => { if (hasAnyAnnotations) { - setExitWarningAction('close'); + setExitWarningAction("close"); setShowExitWarning(true); } else { headerHandlersRef.current.handleAnnotateExit(); @@ -1606,9 +2098,14 @@ const App: React.FC = () => { const h = headerHandlersRef.current; const docAnnotations = h.getDocAnnotations(); const hasDocAnnotations = Array.from(docAnnotations.values()).some( - (d) => d.annotations.length > 0 || d.globalAttachments.length > 0 + (d) => d.annotations.length > 0 || d.globalAttachments.length > 0, ); - if (allAnnotations.length === 0 && codeAnnotations.length === 0 && editorAnnotations.length === 0 && !hasDocAnnotations) { + if ( + allAnnotations.length === 0 && + codeAnnotations.length === 0 && + editorAnnotations.length === 0 && + !hasDocAnnotations + ) { setShowFeedbackPrompt(true); } else { h.handleDeny(); @@ -1619,18 +2116,21 @@ const App: React.FC = () => { const h = headerHandlersRef.current; if (annotateMode) { if (hasAnyAnnotations) { - setExitWarningAction('approve'); + setExitWarningAction("approve"); setShowExitWarning(true); return; } h.handleAnnotateApprove(); return; } - if (origin === 'claude-code' && (allAnnotations.length > 0 || codeAnnotations.length > 0)) { + if ( + origin === "claude-code" && + (allAnnotations.length > 0 || codeAnnotations.length > 0) + ) { setShowClaudeCodeWarning(true); return; } - if (origin === 'opencode') { + if (origin === "opencode") { const warning = h.getAgentWarning(); if (warning) { setAgentWarningMessage(warning); @@ -1639,627 +2139,869 @@ const App: React.FC = () => { } } h.handleApprove(); - }, [annotateMode, hasAnyAnnotations, origin, allAnnotations.length, codeAnnotations.length]); + }, [ + annotateMode, + hasAnyAnnotations, + origin, + allAnnotations.length, + codeAnnotations.length, + ]); - const handleHeaderAnnotateFeedback = useCallback(() => headerHandlersRef.current.handleAnnotateFeedback(), []); - const handleHeaderAnnotateApprove = useCallback(() => headerHandlersRef.current.handleAnnotateApprove(), []); - const handleHeaderDownloadAnnotations = useCallback(() => headerHandlersRef.current.handleDownloadAnnotations(), []); - const handleHeaderCopyAgentInstructions = useCallback(() => headerHandlersRef.current.handleCopyAgentInstructions(), []); - const handleHeaderCopyShareLink = useCallback(() => headerHandlersRef.current.handleCopyShareLink(), []); + const handleHeaderAnnotateFeedback = useCallback( + () => headerHandlersRef.current.handleAnnotateFeedback(), + [], + ); + const handleHeaderAnnotateApprove = useCallback( + () => headerHandlersRef.current.handleAnnotateApprove(), + [], + ); + const handleHeaderDownloadAnnotations = useCallback( + () => headerHandlersRef.current.handleDownloadAnnotations(), + [], + ); + const handleHeaderCopyAgentInstructions = useCallback( + () => headerHandlersRef.current.handleCopyAgentInstructions(), + [], + ); + const handleHeaderCopyShareLink = useCallback( + () => headerHandlersRef.current.handleCopyShareLink(), + [], + ); const handleOpenSettings = useCallback(() => setMobileSettingsOpen(true), []); - const handleCloseSettings = useCallback(() => setMobileSettingsOpen(false), []); - const handleOpenExport = useCallback(() => { setInitialExportTab(undefined); setShowExport(true); }, []); + const handleCloseSettings = useCallback( + () => setMobileSettingsOpen(false), + [], + ); + const handleOpenExport = useCallback(() => { + setInitialExportTab(undefined); + setShowExport(true); + }, []); const handlePrint = useCallback(() => window.print(), []); const handleOpenImport = useCallback(() => setShowImport(true), []); - const handleSaveToObsidian = useCallback(() => headerHandlersRef.current.handleQuickSaveToNotes('obsidian'), []); - const handleSaveToOctarine = useCallback(() => headerHandlersRef.current.handleQuickSaveToNotes('octarine'), []); - const handleSaveToBear = useCallback(() => headerHandlersRef.current.handleQuickSaveToNotes('bear'), []); + const handleSaveToObsidian = useCallback( + () => headerHandlersRef.current.handleQuickSaveToNotes("obsidian"), + [], + ); + const handleSaveToOctarine = useCallback( + () => headerHandlersRef.current.handleQuickSaveToNotes("octarine"), + [], + ); + const handleSaveToBear = useCallback( + () => headerHandlersRef.current.handleQuickSaveToNotes("bear"), + [], + ); const planMaxWidth = useMemo(() => { - const widths: Record = { compact: 832, default: 1040, wide: 1280 }; + const widths: Record = { + compact: 832, + default: 1040, + wide: 1280, + }; return widths[uiPrefs.planWidth] ?? 832; }, [uiPrefs.planWidth]); - const annotateReaderMaxWidth = canUseWideMode && wideModeType === 'wide' ? null : planMaxWidth; - + const annotateReaderMaxWidth = + canUseWideMode && wideModeType === "wide" ? null : planMaxWidth; return ( - -
- 0 || codeAnnotations.length > 0} - callbackConfig={callbackConfig} - taterMode={taterMode} - mobileSettingsOpen={mobileSettingsOpen} - gitUser={gitUser} - onCallbackFeedback={handleCallbackFeedback} - onCallbackApprove={handleCallbackApprove} - onAnnotateExit={handleHeaderAnnotateExit} - onAnnotateFeedback={handleHeaderAnnotateFeedback} - onAnnotateApprove={handleHeaderAnnotateApprove} - onFeedback={handleHeaderFeedback} - onApprove={handleHeaderApprove} - onAnnotationPanelToggle={handleAnnotationPanelToggle} - onArchiveCopy={archive.copy} - onArchiveDone={archive.done} - onTaterModeChange={handleTaterModeChange} - onIdentityChange={handleIdentityChange} - onUIPreferencesChange={setUiPrefs} - onOpenSettings={handleOpenSettings} - onCloseSettings={handleCloseSettings} - onOpenExport={handleOpenExport} - onCopyAgentInstructions={handleHeaderCopyAgentInstructions} - onDownloadAnnotations={handleHeaderDownloadAnnotations} - onPrint={handlePrint} - onCopyShareLink={handleHeaderCopyShareLink} - onOpenImport={handleOpenImport} - onSaveToObsidian={handleSaveToObsidian} - onSaveToBear={handleSaveToBear} - onSaveToOctarine={handleSaveToOctarine} - appVersion={typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'} - agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode} - obsidianConfigured={isObsidianConfigured()} - bearConfigured={getBearSettings().enabled} - octarineConfigured={isOctarineConfigured()} - /> - {/* Linked document error banner */} - {linkedDocHook.error && ( -
- {linkedDocHook.error} - -
- )} - - {/* Main Content */} - -
- {/* Tater sprites — inside content wrapper so z-0 stacking context applies */} - {taterMode && } - {/* Left Sidebar: collapsed tab flags (when sidebar is closed) */} - {wideModeType === null && !sidebar.isOpen && ( - 1} - showFilesTab={showFilesTab && !archive.archiveMode} - hasFileAnnotations={hasFileAnnotations} - className="hidden lg:flex absolute left-0 top-0 z-10" - /> - )} - - {/* Left Sidebar: open state (TOC or Version Browser) */} - {sidebar.isOpen && ( - <> - { - toggleSidebarTab(tab); - if (tab === 'archive' && !archive.archiveMode) archive.fetchPlans(); - }} - onClose={sidebar.close} - width={tocResize.width} - blocks={blocks} - annotations={annotations} - activeSection={activeSection} - onTocNavigate={handleTocNavigate} - linkedDocFilepath={linkedDocHook.filepath} - onLinkedDocBack={linkedDocHook.isActive ? handleLinkedDocBack : undefined} - backLabel={backLabel} - showFilesTab={showFilesTab && !archive.archiveMode} - fileAnnotationCounts={fileAnnotationCounts} - highlightedFiles={highlightedFiles} - fileBrowser={fileBrowser} - onFilesSelectFile={handleFileBrowserSelect} - onFilesFetchAll={() => fileBrowser.fetchAll(fileBrowserDirs)} - onFilesRetryVaultDir={(vaultPath) => fileBrowser.addVaultDir(vaultPath)} - hasFileAnnotations={hasFileAnnotations} - showVersionsTab={versionInfo !== null && versionInfo.totalVersions > 1} - versionInfo={versionInfo} - versions={planDiff.versions} - selectedBaseVersion={planDiff.diffBaseVersion} - onSelectBaseVersion={planDiff.selectBaseVersion} - isPlanDiffActive={isPlanDiffActive} - hasPreviousVersion={planDiff.hasPreviousVersion} - onActivatePlanDiff={() => setIsPlanDiffActive(true)} - isLoadingVersions={planDiff.isLoadingVersions} - isSelectingVersion={planDiff.isSelectingVersion} - fetchingVersion={planDiff.fetchingVersion} - onFetchVersions={planDiff.fetchVersions} - showArchiveTab={isApiMode && !annotateMode} - archivePlans={archive.plans} - selectedArchiveFile={archive.selectedFile} - onArchiveSelect={archive.select} - isLoadingArchive={archive.isLoading} - /> - - + +
+ 0 || codeAnnotations.length > 0 + } + callbackConfig={callbackConfig} + taterMode={taterMode} + mobileSettingsOpen={mobileSettingsOpen} + gitUser={gitUser} + onCallbackFeedback={handleCallbackFeedback} + onCallbackApprove={handleCallbackApprove} + onAnnotateExit={handleHeaderAnnotateExit} + onAnnotateFeedback={handleHeaderAnnotateFeedback} + onAnnotateApprove={handleHeaderAnnotateApprove} + onFeedback={handleHeaderFeedback} + onApprove={handleHeaderApprove} + onAnnotationPanelToggle={handleAnnotationPanelToggle} + onArchiveCopy={archive.copy} + onArchiveDone={archive.done} + onTaterModeChange={handleTaterModeChange} + onIdentityChange={handleIdentityChange} + onUIPreferencesChange={setUiPrefs} + onOpenSettings={handleOpenSettings} + onCloseSettings={handleCloseSettings} + onOpenExport={handleOpenExport} + onCopyAgentInstructions={handleHeaderCopyAgentInstructions} + onDownloadAnnotations={handleHeaderDownloadAnnotations} + onPrint={handlePrint} + onCopyShareLink={handleHeaderCopyShareLink} + onOpenImport={handleOpenImport} + onSaveToObsidian={handleSaveToObsidian} + onSaveToBear={handleSaveToBear} + onSaveToOctarine={handleSaveToOctarine} + appVersion={ + typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "0.0.0" + } + agentInstructionsEnabled={ + isApiMode && !archive.archiveMode && !annotateMode + } + obsidianConfigured={isObsidianConfigured()} + bearConfigured={getBearSettings().enabled} + octarineConfigured={isOctarineConfigured()} + /> + {/* Linked document error banner */} + {linkedDocHook.error && ( +
+ + {linkedDocHook.error} + + +
)} - {/* Document Area */} - - -
- {/* Sticky header lane — ghost bar that pins the toolstrip + - badges at top: 12px once the user scrolls. Invisible at top - of doc; original toolstrip/badges remain the source of - truth there. Hidden in plan diff or archive mode, or when - sticky actions are disabled. remountToken re-anchors the - ResizeObserver when Viewer swaps content (linked docs). */} - {!isPlanDiffActive && !archive.archiveMode && uiPrefs.stickyActionsEnabled && ( - setIsPlanDiffActive(!isPlanDiffActive)} - archiveInfo={archive.currentInfo} - maxWidth={annotateReaderMaxWidth} - remountToken={linkedDocHook.isActive ? `doc:${linkedDocHook.filepath}` : 'plan'} + {/* Main Content */} + +
+ {/* Tater sprites — inside content wrapper so z-0 stacking context applies */} + {taterMode && } + {/* Left Sidebar: collapsed tab flags (when sidebar is closed) */} + {wideModeType === null && !sidebar.isOpen && ( + 1 + } + showFilesTab={showFilesTab && !archive.archiveMode} + hasFileAnnotations={hasFileAnnotations} + className="hidden lg:flex absolute left-0 top-0 z-10" /> )} - {/* Annotation Toolstrip (hidden during plan diff and archive mode) */} - {!isPlanDiffActive && !archive.archiveMode && ( -
- + { + toggleSidebarTab(tab); + if (tab === "archive" && !archive.archiveMode) + archive.fetchPlans(); + }} + onClose={sidebar.close} + width={tocResize.width} + blocks={blocks} + annotations={annotations} + activeSection={activeSection} + onTocNavigate={handleTocNavigate} + linkedDocFilepath={linkedDocHook.filepath} + onLinkedDocBack={ + linkedDocHook.isActive ? handleLinkedDocBack : undefined + } + backLabel={backLabel} + showFilesTab={showFilesTab && !archive.archiveMode} + fileAnnotationCounts={fileAnnotationCounts} + highlightedFiles={highlightedFiles} + fileBrowser={fileBrowser} + onFilesSelectFile={handleFileBrowserSelect} + onFilesFetchAll={() => + fileBrowser.fetchAll(fileBrowserDirs) + } + onFilesRetryVaultDir={(vaultPath) => + fileBrowser.addVaultDir(vaultPath) + } + hasFileAnnotations={hasFileAnnotations} + showVersionsTab={ + versionInfo !== null && versionInfo.totalVersions > 1 + } + versionInfo={versionInfo} + versions={planDiff.versions} + selectedBaseVersion={planDiff.diffBaseVersion} + onSelectBaseVersion={planDiff.selectBaseVersion} + isPlanDiffActive={isPlanDiffActive} + hasPreviousVersion={planDiff.hasPreviousVersion} + onActivatePlanDiff={() => setIsPlanDiffActive(true)} + isLoadingVersions={planDiff.isLoadingVersions} + isSelectingVersion={planDiff.isSelectingVersion} + fetchingVersion={planDiff.fetchingVersion} + onFetchVersions={planDiff.fetchVersions} + showArchiveTab={isApiMode && !annotateMode} + archivePlans={archive.plans} + selectedArchiveFile={archive.selectedFile} + onArchiveSelect={archive.select} + isLoadingArchive={archive.isLoading} /> -
- )} - - {/* Plan Diff View — rendered when diff data exists, hidden when inactive */} - {planDiff.diffBlocks && planDiff.diffStats && ( -
- setIsPlanDiffActive(false)} - repoInfo={repoInfo} - baseVersionLabel={planDiff.diffBaseVersion != null ? `v${planDiff.diffBaseVersion}` : undefined} - baseVersion={planDiff.diffBaseVersion ?? undefined} - maxWidth={planMaxWidth} - annotations={diffAnnotations} - onAddAnnotation={handleAddAnnotation} - onSelectAnnotation={handleSelectAnnotation} - selectedAnnotationId={selectedAnnotationId} - mode={editorMode} + -
+ )} - {/* Folder annotation empty state — shown before user picks a file */} - {annotateSource === 'folder' && !markdown && !linkedDocHook.isActive && ( -
-
-

Select a file to annotate

-

Pick a markdown file from the sidebar to begin.

-
-
- )} - {/* Normal Plan View — always mounted, hidden during diff mode */} -
- {canUseWideMode && !isPlanDiffActive && !archive.archiveMode && ( + + {/* Document Area */} + + +
+ {/* Sticky header lane — ghost bar that pins the toolstrip + + badges at top: 12px once the user scrolls. Invisible at top + of doc; original toolstrip/badges remain the source of + truth there. Hidden in plan diff or archive mode, or when + sticky actions are disabled. remountToken re-anchors the + ResizeObserver when Viewer swaps content (linked docs). */} + {!isPlanDiffActive && + !archive.archiveMode && + uiPrefs.stickyActionsEnabled && ( + + setIsPlanDiffActive(!isPlanDiffActive) + } + archiveInfo={archive.currentInfo} + maxWidth={annotateReaderMaxWidth} + remountToken={ + linkedDocHook.isActive + ? `doc:${linkedDocHook.filepath}` + : "plan" + } + /> + )} + + {/* Annotation Toolstrip (hidden during plan diff and archive mode) */} + {!isPlanDiffActive && !archive.archiveMode && ( +
+ +
+ )} + + {/* Plan Diff View — rendered when diff data exists, hidden when inactive */} + {planDiff.diffBlocks && planDiff.diffStats && ( +
+ setIsPlanDiffActive(false)} + repoInfo={repoInfo} + baseVersionLabel={ + planDiff.diffBaseVersion != null + ? `v${planDiff.diffBaseVersion}` + : undefined + } + baseVersion={planDiff.diffBaseVersion ?? undefined} + maxWidth={planMaxWidth} + annotations={diffAnnotations} + onAddAnnotation={handleAddAnnotation} + onSelectAnnotation={handleSelectAnnotation} + selectedAnnotationId={selectedAnnotationId} + mode={editorMode} + /> +
+ )} + {/* Folder annotation empty state — shown before user picks a file */} + {annotateSource === "folder" && + !markdown && + !linkedDocHook.isActive && ( +
+
+

+ Select a file to annotate +

+

+ Pick a markdown file from the sidebar to begin. +

+
+
+ )} + {/* Normal Plan View — always mounted, hidden during diff mode */}
-
- {(['wide', 'focus'] as const).map((type, i) => ( - - {i > 0 && |} - +
- - - - ))} -
+ {(["wide", "focus"] as const).map((type, i) => ( + + {i > 0 && ( + + | + + )} + + + + + ))} +
+
+ )} + {renderAs === "html" ? ( + + ) : ( + + setIsPlanDiffActive(!isPlanDiffActive) + } + hasPreviousVersion={ + !linkedDocHook.isActive && planDiff.hasPreviousVersion + } + showDemoBadge={ + !isApiMode && !isLoadingShared && !isSharedSession + } + maxWidth={annotateReaderMaxWidth} + onOpenLinkedDoc={handleOpenLinkedDoc} + onOpenCodeFile={codeFilePopout.open} + linkedDocInfo={ + linkedDocHook.isActive + ? { + filepath: linkedDocHook.filepath!, + onBack: handleLinkedDocBack, + label: fileBrowser.dirs.find( + (d) => d.path === fileBrowser.activeDirPath, + )?.isVault + ? "Vault File" + : fileBrowser.activeFile + ? "File" + : undefined, + backLabel, + } + : null + } + imageBaseDir={imageBaseDir} + codePathBaseDir={activeDocBaseDir} + copyLabel={ + annotateSource === "message" + ? "Copy message" + : annotateSource === "file" || + annotateSource === "folder" + ? "Copy file" + : undefined + } + archiveInfo={archive.currentInfo} + sourceInfo={sourceInfo} + onToggleCheckbox={checkbox.toggle} + checkboxOverrides={checkbox.overrides} + actionsLabelMode={actionsLabelMode} + /> + )}
- )} - {renderAs === 'html' ? ( - - ) : ( - setIsPlanDiffActive(!isPlanDiffActive)} - hasPreviousVersion={!linkedDocHook.isActive && planDiff.hasPreviousVersion} - showDemoBadge={!isApiMode && !isLoadingShared && !isSharedSession} - maxWidth={annotateReaderMaxWidth} - onOpenLinkedDoc={handleOpenLinkedDoc} - onOpenCodeFile={codeFilePopout.open} - linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: handleLinkedDocBack, label: fileBrowser.dirs.find(d => d.path === fileBrowser.activeDirPath)?.isVault ? 'Vault File' : fileBrowser.activeFile ? 'File' : undefined, backLabel } : null} - imageBaseDir={imageBaseDir} - codePathBaseDir={activeDocBaseDir} - copyLabel={annotateSource === 'message' ? 'Copy message' : annotateSource === 'file' || annotateSource === 'folder' ? 'Copy file' : undefined} - archiveInfo={archive.currentInfo} - sourceInfo={sourceInfo} - onToggleCheckbox={checkbox.toggle} - checkboxOverrides={checkbox.overrides} - actionsLabelMode={actionsLabelMode} - /> - )} -
+
+ + + {/* Resize Handle */} + {isPanelOpen && wideModeType === null && ( + + )} + + {/* Annotation Panel */} + setIsPanelOpen(false)} + onQuickCopy={async () => { + await navigator.clipboard.writeText( + wrapFeedbackForAgent(annotationsOutput), + ); + }} + onShare={ + canShareCurrentSession && (shareUrl || shortShareUrl) + ? () => { + setIsPanelOpen(false); + setInitialExportTab("share"); + setShowExport(true); + } + : undefined + } + otherFileAnnotations={otherFileAnnotations} + onOtherFileAnnotationsClick={handleFlashAnnotatedFiles} + />
-
- - {/* Resize Handle */} - {isPanelOpen && wideModeType === null && } - - {/* Annotation Panel */} - + + {/* Code File Popout */} + {codeFilePopout.popoutProps && ( + ann.filePath === codeFilePopout.popoutProps?.filepath, + )} + selectedAnnotationId={selectedCodeAnnotationId} + onAddAnnotation={handleAddCodeAnnotation} + onEditAnnotation={handleEditCodeAnnotation} + onDeleteAnnotation={handleDeleteCodeAnnotation} + onSelectAnnotation={(id) => { + setSelectedAnnotationId(null); + setSelectedCodeAnnotationId(id); + }} + /> + )} + + {/* Export Modal */} + { + setShowExport(false); + setInitialExportTab(undefined); + }} + shareUrl={shareUrl} + shareUrlSize={shareUrlSize} + shortShareUrl={shortShareUrl} + isGeneratingShortUrl={isGeneratingShortUrl} + shortUrlError={shortUrlError} + onGenerateShortUrl={generateShortUrl} + annotationsOutput={annotationsOutput} + annotationCount={allAnnotations.length + codeAnnotations.length} + taterSprite={taterMode ? : undefined} sharingEnabled={canShareCurrentSession} - width={panelResize.width} - editorAnnotations={editorAnnotations} - onDeleteEditorAnnotation={deleteEditorAnnotation} - onClose={() => setIsPanelOpen(false)} - onQuickCopy={async () => { - await navigator.clipboard.writeText(wrapFeedbackForAgent(annotationsOutput)); + markdown={markdown} + isApiMode={isApiMode} + initialTab={initialExportTab} + /> + + {/* Import Modal */} + setShowImport(false)} + onImport={importFromShareUrl} + shareBaseUrl={shareBaseUrl} + /> + + {/* Feedback prompt dialog */} + setShowFeedbackPrompt(false)} + title="Add Annotations First" + message={`To provide feedback, select text in the plan and add annotations. ${agentName} will use your annotations to revise the plan.`} + variant="info" + /> + + {/* Claude Code annotation warning dialog */} + setShowClaudeCodeWarning(false)} + onConfirm={() => { + setShowClaudeCodeWarning(false); + const override = pendingApprovalOverride; + setPendingApprovalOverride({}); + handleApprove(override); }} - onShare={canShareCurrentSession && (shareUrl || shortShareUrl) ? () => { setIsPanelOpen(false); setInitialExportTab('share'); setShowExport(true); } : undefined} - otherFileAnnotations={otherFileAnnotations} - onOtherFileAnnotationsClick={handleFlashAnnotatedFiles} + title="Annotations Won't Be Sent" + message={ + <> + {agentName} doesn't yet support feedback on approval. Your{" "} + {allAnnotations.length + codeAnnotations.length} annotation + {allAnnotations.length + codeAnnotations.length !== 1 + ? "s" + : ""}{" "} + will be lost. + + } + subMessage={ + <> + To send feedback, use Send Feedback instead. +
+
+ Want this feature? Upvote these issues: +
+ + #16001 + + {" · "} + + #15755 + + + } + confirmText="Approve Anyway" + cancelText="Cancel" + variant="warning" + showCancel /> -
- - - {/* Code File Popout */} - {codeFilePopout.popoutProps && ( - ann.filePath === codeFilePopout.popoutProps?.filepath)} - selectedAnnotationId={selectedCodeAnnotationId} - onAddAnnotation={handleAddCodeAnnotation} - onEditAnnotation={handleEditCodeAnnotation} - onDeleteAnnotation={handleDeleteCodeAnnotation} - onSelectAnnotation={(id) => { - setSelectedAnnotationId(null); - setSelectedCodeAnnotationId(id); + + {/* Unsaved-annotations warning dialog — reused by Close and (in gate mode) Approve */} + setShowExitWarning(false)} + onConfirm={() => { + setShowExitWarning(false); + if (exitWarningAction === "approve") handleAnnotateApprove(); + else handleAnnotateExit(); }} + title="Annotations Won't Be Sent" + message={ + <> + You have {feedbackAnnotationCount} annotation + {feedbackAnnotationCount !== 1 ? "s" : ""} that will be lost if + you {exitWarningAction === "approve" ? "approve" : "close"}. + + } + subMessage="To send your annotations, use Send Annotations instead." + confirmText={ + exitWarningAction === "approve" + ? "Approve Anyway" + : "Close Anyway" + } + cancelText="Cancel" + variant="warning" + showCancel /> - )} - - {/* Export Modal */} - { setShowExport(false); setInitialExportTab(undefined); }} - shareUrl={shareUrl} - shareUrlSize={shareUrlSize} - shortShareUrl={shortShareUrl} - isGeneratingShortUrl={isGeneratingShortUrl} - shortUrlError={shortUrlError} - onGenerateShortUrl={generateShortUrl} - annotationsOutput={annotationsOutput} - annotationCount={allAnnotations.length + codeAnnotations.length} - taterSprite={taterMode ? : undefined} - sharingEnabled={canShareCurrentSession} - markdown={markdown} - isApiMode={isApiMode} - initialTab={initialExportTab} - /> - - {/* Import Modal */} - setShowImport(false)} - onImport={importFromShareUrl} - shareBaseUrl={shareBaseUrl} - /> - - {/* Feedback prompt dialog */} - setShowFeedbackPrompt(false)} - title="Add Annotations First" - message={`To provide feedback, select text in the plan and add annotations. ${agentName} will use your annotations to revise the plan.`} - variant="info" - /> - - {/* Claude Code annotation warning dialog */} - setShowClaudeCodeWarning(false)} - onConfirm={() => { - setShowClaudeCodeWarning(false); - const override = pendingApprovalOverride; - setPendingApprovalOverride({}); - handleApprove(override); - }} - title="Annotations Won't Be Sent" - message={<>{agentName} doesn't yet support feedback on approval. Your {allAnnotations.length + codeAnnotations.length} annotation{(allAnnotations.length + codeAnnotations.length) !== 1 ? 's' : ''} will be lost.} - subMessage={ - <> - To send feedback, use Send Feedback instead. -

- Want this feature? Upvote these issues: -
- #16001 - {' · '} - #15755 - - } - confirmText="Approve Anyway" - cancelText="Cancel" - variant="warning" - showCancel - /> - - {/* Unsaved-annotations warning dialog — reused by Close and (in gate mode) Approve */} - setShowExitWarning(false)} - onConfirm={() => { - setShowExitWarning(false); - if (exitWarningAction === 'approve') handleAnnotateApprove(); - else handleAnnotateExit(); - }} - title="Annotations Won't Be Sent" - message={<>You have {feedbackAnnotationCount} annotation{feedbackAnnotationCount !== 1 ? 's' : ''} that will be lost if you {exitWarningAction === 'approve' ? 'approve' : 'close'}.} - subMessage="To send your annotations, use Send Annotations instead." - confirmText={exitWarningAction === 'approve' ? 'Approve Anyway' : 'Close Anyway'} - cancelText="Cancel" - variant="warning" - showCancel - /> - - {/* OpenCode agent not found warning dialog */} - setShowAgentWarning(false)} - onConfirm={() => { - setShowAgentWarning(false); - handleApprove(); - }} - title="Agent Not Found" - message={agentWarningMessage} - subMessage={ - <> - You can change the agent in Settings, or approve anyway and OpenCode will use the default agent. - - } - confirmText="Approve Anyway" - cancelText="Cancel" - variant="warning" - showCancel - /> - - {/* Shared URL load failure warning */} - - - - - {/* Completion overlay - shown after approve/deny */} - - - {/* Update notification */} - - - {showClearContextBanner && ( -
setShowAgentWarning(false)} + onConfirm={() => { + setShowAgentWarning(false); + handleApprove(); }} - > -
- Enable native clear-on-accept? -
-
- Plannotator will write{' '} - showClearContextOnPlanAccept: true to your Claude - Code settings so Claude Code can clear planning context through - its native approval flow. -
-
- - + + }} + style={{ + padding: "4px 10px", + cursor: "pointer", + fontWeight: 600, + }} + > + Enable + +
-
- )} - - {/* Image Annotator for pasted images */} - - - {/* Permission Mode Setup (Claude Code first-time) */} - { - setPermissionMode(mode); - setShowPermissionModeSetup(false); - }} - /> -
+ )} + + {/* Image Annotator for pasted images */} + + + {/* Permission Mode Setup (Claude Code first-time) */} + { + setPermissionMode(mode); + setShowPermissionModeSetup(false); + }} + /> +
); From 22315a4a0946b664b1486865960be2ecfcc668bf Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:49:40 +0700 Subject: [PATCH 36/39] fix(hook): read CC plan content from plansDirectory, not inline payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CC's ExitPlanMode has no `plan` parameter — it writes the plan to disk and reads it back internally. The PermissionRequest hook therefore never received plan content via event.tool_input.plan, so planContent was always empty, triggering "No plan content in hook event" + exit 1. CC then fell through to its own native plan dialog and the Plannotator web UI never launched. Add readLatestCCPlanFile(): resolve plansDirectory from ~/.claude/settings.json (default "claude-code-plans", relative to cwd matching CC's own resolution) and read the most recently modified .md. Per-file statSync is wrapped so a single broken symlink among many plan files can't blank out the whole resolution. Verified: from a normal project cwd the server binds in ~1s and serves the Plannotator UI for the previously-failing empty-tool_input payload. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hook/server/index.ts | 53 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 0b9eef23a..b915384e3 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -108,7 +108,7 @@ import { hasMarkdownFiles, } from "@plannotator/shared/resolve-file"; import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { statSync, rmSync, realpathSync, existsSync } from "fs"; +import { statSync, rmSync, realpathSync, existsSync, readdirSync } from "fs"; import { parseRemoteUrl } from "@plannotator/shared/repo"; import { getReviewApprovedPrompt, @@ -1339,6 +1339,49 @@ if (args[0] === "sessions") { // PLAN REVIEW MODE (default) // ============================================ + /** + * Read the most recently modified .md file from CC's plansDirectory. + * + * CC's ExitPlanMode tool has no `plan` parameter — it writes the plan to + * disk and reads it back internally. The PermissionRequest hook therefore + * never receives plan content inline; the only reliable source is the file + * CC wrote. This is the intended path for CC, not a fallback shim. + */ + async function readLatestCCPlanFile(): Promise { + try { + const settingsPath = path.join( + process.env.HOME ?? "", + ".claude", + "settings.json", + ); + if (!existsSync(settingsPath)) return ""; + const settings: Record = JSON.parse( + await Bun.file(settingsPath).text(), + ); + const plansDir: string = settings.plansDirectory ?? "claude-code-plans"; + const resolved = path.isAbsolute(plansDir) + ? plansDir + : path.join(process.cwd(), plansDir); + if (!existsSync(resolved)) return ""; + const newest = readdirSync(resolved) + .filter((f) => f.endsWith(".md")) + .map((f) => { + // Tolerate broken symlinks / races: a single unreadable entry + // must not blank out the whole resolution. mtime -1 sorts last. + try { + return { f, mtime: statSync(path.join(resolved, f)).mtimeMs }; + } catch { + return { f, mtime: -1 }; + } + }) + .filter((e) => e.mtime >= 0) + .sort((a, b) => b.mtime - a.mtime)[0]; + return newest ? await Bun.file(path.join(resolved, newest.f)).text() : ""; + } catch { + return ""; + } + } + // Read hook event from stdin const eventJson = await Bun.stdin.text(); if (!eventJson.trim()) { @@ -1432,7 +1475,7 @@ if (args[0] === "sessions") { let isGemini = false; let planFilename = ""; - // Detect harness: Gemini sends plan_filename (file on disk), Claude Code sends plan (inline) + // Detect harness: Gemini sends plan_filename (file on disk), CC reads from plansDirectory planFilename = event.tool_input?.plan_filename || event.tool_input?.plan_path || ""; isGemini = !!planFilename; @@ -1450,7 +1493,11 @@ if (args[0] === "sessions") { ); planContent = await Bun.file(planFilePath).text(); } else { - planContent = event.tool_input?.plan || ""; + // CC does not inline plan content in the PermissionRequest hook payload — + // ExitPlanMode has no `plan` parameter. Fall back to the most recently + // modified .md file in plansDirectory (relative to cwd, matching CC's + // own resolution of the plansDirectory setting). + planContent = event.tool_input?.plan || (await readLatestCCPlanFile()); } permissionMode = event.permission_mode || "default"; From bdd8f1e53eedfb508710fa48cecc939236d512f2 Mon Sep 17 00:00:00 2001 From: Rhen Khong <127654909+AgileInnov8tor@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:24:56 +0700 Subject: [PATCH 37/39] refactor(clear-context): one-time consent ref guard + deferNative mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace bypassPermissionsClearReminder with deferNative. Add clearContextConsentResolvedRef so the consent banner fires at most once per approval flow rather than on every Approve click. - App.tsx: consent guard ref + pause-and-resume on enable-clear-context failure; remove approve-bypass-clear-reminder dropdown option - permissionMode.ts: drop bypassPermissionsClearReminder, add deferNative - index.ts: shouldAutoSelectNativeClear → shouldFireInjector + log call - keystrokeInjector.ts: logInjectorDecision + shouldFireInjector exports - approvalBody.ts: align with updated PermissionMode union - AppHeader.tsx: clear-context banner Enable/Skip resolve consent + resume - Test files: update suites to match refactored API surface Co-Authored-By: Claude Sonnet 4.6 --- apps/hook/server/index.ts | 7 +- apps/hook/server/keystrokeInjector.test.ts | 47 +++- apps/hook/server/keystrokeInjector.ts | 70 ++++- packages/editor/App.tsx | 66 +++-- packages/editor/approvalBody.test.ts | 258 +++++++----------- packages/editor/approvalBody.ts | 36 ++- packages/editor/components/AppHeader.test.tsx | 109 ++++++++ packages/editor/components/AppHeader.tsx | 50 +++- .../ui/components/ApproveDropdown.test.tsx | 30 +- packages/ui/components/ApproveDropdown.tsx | 8 + packages/ui/utils/permissionMode.test.ts | 13 + packages/ui/utils/permissionMode.ts | 53 ++-- 12 files changed, 495 insertions(+), 252 deletions(-) create mode 100644 packages/editor/components/AppHeader.test.tsx create mode 100644 packages/ui/utils/permissionMode.test.ts diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index b915384e3..30ba9bc93 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -156,7 +156,8 @@ import { } from "./cli"; import { ensureClearContextSettingEnabled } from "./clearContextSetting"; import { - shouldAutoSelectNativeClear, + logInjectorDecision, + shouldFireInjector, spawnKeystrokeInjector, } from "./keystrokeInjector"; import path from "path"; @@ -1594,7 +1595,9 @@ if (args[0] === "sessions") { // PermissionRequestHookSpecificOutput.decision. const nativeClearEnabled = await ensureClearContextSettingEnabled(); if (nativeClearEnabled) { - if (shouldAutoSelectNativeClear()) { + const fire = shouldFireInjector(result); + logInjectorDecision(result, fire); + if (fire) { spawnKeystrokeInjector(); } process.exit(0); diff --git a/apps/hook/server/keystrokeInjector.test.ts b/apps/hook/server/keystrokeInjector.test.ts index 5391a6d94..66424d617 100644 --- a/apps/hook/server/keystrokeInjector.test.ts +++ b/apps/hook/server/keystrokeInjector.test.ts @@ -7,7 +7,42 @@ import { beforeEach, afterEach, } from "bun:test"; -import { shouldAutoSelectNativeClear, spawnKeystrokeInjector } from "./keystrokeInjector"; +import { + shouldAutoSelectNativeClear, + shouldFireInjector, + spawnKeystrokeInjector, +} from "./keystrokeInjector"; + +describe("shouldFireInjector", () => { + const noEnv = {} as NodeJS.ProcessEnv; + + it("fires for one-shot deferToNativeForClear", () => { + expect(shouldFireInjector({ deferToNativeForClear: true }, noEnv)).toBe( + true, + ); + }); + + it("fires for persistent permissionMode deferNative", () => { + expect(shouldFireInjector({ permissionMode: "deferNative" }, noEnv)).toBe( + true, + ); + }); + + it("fires when env override is set", () => { + expect( + shouldFireInjector({}, { + PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR: "1", + } as NodeJS.ProcessEnv), + ).toBe(true); + }); + + it("does not fire without flag, mode, or env", () => { + expect( + shouldFireInjector({ permissionMode: "bypassPermissions" }, noEnv), + ).toBe(false); + expect(shouldFireInjector({}, noEnv)).toBe(false); + }); +}); describe("shouldAutoSelectNativeClear", () => { it("defaults to false", () => { @@ -86,6 +121,16 @@ describe("spawnKeystrokeInjector", () => { expect(script).toContain("Terminal"); expect(script).toContain('keystroke "1"'); expect(script).not.toContain("tmux send-keys"); + // Accessibility self-check + expect(script).toContain("UI elements enabled"); + expect(script).toContain("accessibility-not-enabled"); + expect(script).toContain('log "accessibility=true"'); + // Warp timing hardening + expect(script).toContain("delay 0.30"); + expect(script).toContain("delay 0.15"); + // Positive observability markers + expect(script).toContain("start"); + expect(script).toContain('log "injected"'); }); it("no-op on linux without tmux", () => { diff --git a/apps/hook/server/keystrokeInjector.ts b/apps/hook/server/keystrokeInjector.ts index 81bc816f8..d79f90896 100644 --- a/apps/hook/server/keystrokeInjector.ts +++ b/apps/hook/server/keystrokeInjector.ts @@ -1,40 +1,98 @@ // Opt-in helper that injects "1\n" into CC terminal to auto-select native clear + bypass. +import { appendFileSync, mkdirSync } from "node:fs"; + const MACOS_PROCESS_TERMINALS = ["iTerm2", "Terminal"]; -export function shouldAutoSelectNativeClear(env: NodeJS.ProcessEnv = process.env): boolean { +// osascript stderr is redirected here so an Accessibility/Automation (TCC) +// denial is diagnosable instead of failing silently. +const INJECTOR_LOG_DIR = `${process.env["HOME"] ?? "~"}/.plannotator`; +const INJECTOR_LOG = `${INJECTOR_LOG_DIR}/keystroke-injector.log`; + +export function shouldAutoSelectNativeClear( + env: NodeJS.ProcessEnv = process.env, +): boolean { return env["PLANNOTATOR_AUTO_SELECT_NATIVE_CLEAR"] === "1"; } -export function spawnKeystrokeInjector(delayMs = 600): void { +// Both UI paths into the native-defer branch are explicit user opt-ins: the +// one-shot dialog button sends deferToNativeForClear, the persistent +// permission-mode setting sends permissionMode="deferNative" (configured in +// settings, replayed from storage). Either fires the injector; the env var +// remains as a force-on override for other flows. +export function shouldFireInjector( + result: { deferToNativeForClear?: boolean; permissionMode?: string }, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return ( + result.deferToNativeForClear === true || + result.permissionMode === "deferNative" || + shouldAutoSelectNativeClear(env) + ); +} + +// Records why the defer branch did/didn't inject — an empty log was previously +// ambiguous between "branch not entered" and "gate skipped". +export function logInjectorDecision( + result: { deferToNativeForClear?: boolean; permissionMode?: string }, + fired: boolean, +): void { + try { + mkdirSync(INJECTOR_LOG_DIR, { recursive: true }); + const time = new Date().toISOString(); + appendFileSync( + INJECTOR_LOG, + `${time} defer-branch flag=${result.deferToNativeForClear === true} mode=${result.permissionMode ?? "none"} injector=${fired ? "fired" : "skipped"}\n`, + ); + } catch { + // Logging must never block the approval flow. + } +} + +// Default delay raised from 600ms: CC needs time to render its native dialog +// after exit(0). Firing too early lands "1\n" before the dialog exists. No +// repeat-spray — a stray keystroke after the dialog closes lands in the prompt. +export function spawnKeystrokeInjector(delayMs = 1200): void { const delaySec = (delayMs / 1000).toFixed(2); const tmuxPane = process.env["TMUX_PANE"]; let script: string | null = null; if (tmuxPane) { - script = `sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(tmuxPane)} 1 Enter`; + script = `mkdir -p ${JSON.stringify(INJECTOR_LOG_DIR)}; echo "$(date +%T) start" >> ${JSON.stringify(INJECTOR_LOG)}; sleep ${delaySec} && tmux send-keys -t ${JSON.stringify(tmuxPane)} 1 Enter 2>>${JSON.stringify(INJECTOR_LOG)} && echo "$(date +%T) injected" >> ${JSON.stringify(INJECTOR_LOG)}`; } else if (process.platform === "darwin") { const apps = MACOS_PROCESS_TERMINALS.map((a) => `"${a}"`).join(", "); // Warp ships as Warp.app/MacOS/stable so its process name is "stable", not "warp". // Check by bundle name first, then fall back to process-name search for other terminals. script = [ - `osascript <<'APPLESCRIPT'`, + `mkdir -p ${JSON.stringify(INJECTOR_LOG_DIR)}`, + `echo "$(date +%T) start" >> ${JSON.stringify(INJECTOR_LOG)}`, + `osascript 2>>${JSON.stringify(INJECTOR_LOG)} <<'APPLESCRIPT'`, + `tell application "System Events" to set axEnabled to (UI elements enabled)`, + `if not axEnabled then`, + ` log "accessibility-not-enabled — grant Accessibility in System Settings → Privacy & Security → Accessibility"`, + ` return`, + `end if`, + `log "accessibility=true"`, `delay ${delaySec}`, `if application "Warp" is running then`, ` tell application "Warp" to activate`, - ` delay 0.05`, + ` delay 0.30`, ` tell application "System Events"`, ` keystroke "1"`, + ` delay 0.15`, ` key code 36`, ` end tell`, + ` log "injected"`, `else`, ` tell application "System Events"`, ` repeat with appName in {${apps}}`, ` if exists (application process (appName as string)) then`, ` set frontmost of application process (appName as string) to true`, - ` delay 0.05`, + ` delay 0.30`, ` keystroke "1"`, + ` delay 0.15`, ` key code 36`, + ` log "injected"`, ` exit repeat`, ` end if`, ` end repeat`, diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index a90d2b181..b25c6a74a 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -231,6 +231,10 @@ const App: React.FC = () => { const [origin, setOrigin] = useState(null); const [pendingToolName, setPendingToolName] = useState(); const [showClearContextBanner, setShowClearContextBanner] = useState(false); + // Once the user has Enabled or Skipped the native clear-on-accept consent, + // we must not re-show the banner on subsequent Approve clicks. This guards + // against the consent prompt re-triggering indefinitely. + const clearContextConsentResolvedRef = useRef(false); const [pendingApprovalOverride, setPendingApprovalOverride] = useState({}); const [gitUser, setGitUser] = useState(); @@ -1285,17 +1289,30 @@ const App: React.FC = () => { const shouldUseNativeClear = origin === "claude-code" && pendingToolName === "ExitPlanMode" && - (override.deferToNativeForClear || - effectiveMode === "bypassPermissionsClearReminder" || - effectiveMode === "deferNative"); - if (shouldUseNativeClear) { + (override.deferToNativeForClear || effectiveMode === "deferNative"); + if (shouldUseNativeClear && !clearContextConsentResolvedRef.current) { + // Native clear writes to the user's Claude Code settings, so it needs + // a one-time consent. Try to enable silently; if that fails, PAUSE the + // approval and surface the consent banner instead of proceeding. The + // banner's Enable/Skip buttons resolve consent and resume the approval, + // so the prompt is shown at most once rather than on every Approve. + let enabled = false; try { const response = await fetch("/api/enable-clear-context", { method: "POST", }); - if (response.ok) setShowClearContextBanner(false); + enabled = response.ok; } catch { + enabled = false; + } + if (enabled) { + clearContextConsentResolvedRef.current = true; + setShowClearContextBanner(false); + } else { + setPendingApprovalOverride(override); setShowClearContextBanner(true); + setIsSubmitting(false); + return; } } @@ -1426,19 +1443,7 @@ const App: React.FC = () => { }, ]; } - return [ - { - id: "approve-bypass-clear-reminder", - label: "Approve + Bypass + /clear Reminder", - description: - "Requests bypass mode and reminds you to run /clear. Hooks cannot clear context directly outside plan acceptance.", - onSelect: () => - approveWithClaudeCodeWarning({ - permissionMode: "bypassPermissions", - clearContextNudge: true, - }), - }, - ]; + return []; }, [approveWithClaudeCodeWarning, origin, pendingToolName]); // Annotate mode handler — sends feedback via /api/feedback @@ -2952,7 +2957,11 @@ const App: React.FC = () => { > )} @@ -146,7 +201,8 @@ export const AnnotationPanel: React.FC = ({ className="px-3 pb-2 text-[10px] text-primary/70 hover:text-primary transition-colors cursor-pointer" title="Show annotated files in sidebar" > - +{otherFileAnnotations.count} in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'} + +{otherFileAnnotations.count} in {otherFileAnnotations.files} other + file{otherFileAnnotations.files === 1 ? "" : "s"} )}
@@ -154,61 +210,73 @@ export const AnnotationPanel: React.FC = ({ {/* List */}
- {totalCount === 0 ? ( -
-

- No annotations yet -

-

- Select text to annotate -

-
- ) : ( - <> - {timelineEntries.map(entry => ( - entry.kind === 'plan' ? ( - onSelect(entry.annotation.id)} - onDelete={() => onDelete(entry.annotation.id)} - onEdit={onEdit ? (updates: Partial) => onEdit(entry.annotation.id, updates) : undefined} - /> - ) : ( - onSelectCodeAnnotation?.(entry.annotation.id)} - onDelete={() => onDeleteCodeAnnotation?.(entry.annotation.id)} - onEdit={onEditCodeAnnotation ? (updates: Partial) => onEditCodeAnnotation(entry.annotation.id, updates) : undefined} - /> - ) - ))} - {editorAnnotations && editorAnnotations.length > 0 && ( - <> - {timelineEntries.length > 0 && ( -
-
- Editor -
-
- )} - {editorAnnotations.map(ann => ( - onDeleteEditorAnnotation?.(ann.id)} + {totalCount === 0 ? ( +
+

+ Select text or code lines to add annotations +

+
+ ) : ( + <> + {timelineEntries.map((entry) => + entry.kind === "plan" ? ( + onSelect(entry.annotation.id)} + onDelete={() => onDelete(entry.annotation.id)} + onEdit={ + onEdit + ? (updates: Partial) => + onEdit(entry.annotation.id, updates) + : undefined + } /> - ))} - - )} - - - )} + ) : ( + + onSelectCodeAnnotation?.(entry.annotation.id) + } + onDelete={() => + onDeleteCodeAnnotation?.(entry.annotation.id) + } + onEdit={ + onEditCodeAnnotation + ? (updates: Partial) => + onEditCodeAnnotation(entry.annotation.id, updates) + : undefined + } + /> + ), + )} + {editorAnnotations && editorAnnotations.length > 0 && ( + <> + {timelineEntries.length > 0 && ( +
+
+ + Editor + +
+
+ )} + {editorAnnotations.map((ann) => ( + onDeleteEditorAnnotation?.(ann.id)} + /> + ))} + + )} + + )}
@@ -223,20 +291,42 @@ export const AnnotationPanel: React.FC = ({ setTimeout(() => setCopiedText(false), 2000); }} className={`flex-1 flex items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-medium transition-colors ${ - copiedText ? 'text-green-500' : 'text-muted-foreground hover:bg-surface-1 hover:text-foreground' + copiedText + ? "text-green-500" + : "text-muted-foreground hover:bg-surface-1 hover:text-foreground" }`} > {copiedText ? ( <> - - + + Copied ) : ( <> - - + + Copy @@ -248,8 +338,18 @@ export const AnnotationPanel: React.FC = ({ onClick={onShare} className="flex-1 flex items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-medium transition-colors text-muted-foreground hover:bg-surface-1 hover:text-foreground" > - - + + Share @@ -282,12 +382,15 @@ function formatTimestamp(ts: number): string { const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); - if (seconds < 60) return 'now'; + if (seconds < 60) return "now"; if (minutes < 60) return `${minutes}m`; if (hours < 24) return `${hours}h`; if (days < 7) return `${days}d`; - return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return new Date(ts).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); } const AnnotationCard: React.FC<{ @@ -299,7 +402,7 @@ const AnnotationCard: React.FC<{ onEdit?: (updates: Partial) => void; }> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit }) => { const [isEditing, setIsEditing] = useState(false); - const [editText, setEditText] = useState(annotation.text || ''); + const [editText, setEditText] = useState(annotation.text || ""); const textareaRef = useRef(null); useEffect(() => { @@ -312,13 +415,13 @@ const AnnotationCard: React.FC<{ // Update editText when annotation.text changes useEffect(() => { if (!isEditing) { - setEditText(annotation.text || ''); + setEditText(annotation.text || ""); } }, [annotation.text, isEditing]); const handleStartEdit = (e: React.MouseEvent) => { e.stopPropagation(); - setEditText(annotation.text || ''); + setEditText(annotation.text || ""); setIsEditing(true); }; @@ -330,22 +433,26 @@ const AnnotationCard: React.FC<{ }; const handleCancelEdit = () => { - setEditText(annotation.text || ''); + setEditText(annotation.text || ""); setIsEditing(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) { + if ( + e.key === "Enter" && + (e.metaKey || e.ctrlKey) && + !e.nativeEvent.isComposing + ) { e.preventDefault(); handleSaveEdit(); - } else if (e.key === 'Escape') { + } else if (e.key === "Escape") { e.preventDefault(); handleCancelEdit(); } }; - const typeColor = TYPE_COLOR[annotation.type] ?? 'text-muted-foreground'; - const typeLabel = TYPE_LABEL[annotation.type] ?? 'Note'; + const typeColor = TYPE_COLOR[annotation.type] ?? "text-muted-foreground"; + const typeLabel = TYPE_LABEL[annotation.type] ?? "Note"; const isGlobal = annotation.type === AnnotationType.GLOBAL_COMMENT; // Shared edit textarea — matches the prototype composer primitive @@ -354,16 +461,22 @@ const AnnotationCard: React.FC<{
- - + +
); @@ -375,41 +488,56 @@ const AnnotationCard: React.FC<{ tabIndex={0} onClick={onSelect} onKeyDown={(e: React.KeyboardEvent) => { - if ((e.key === 'Enter' || e.key === ' ') && e.target === e.currentTarget) { + if ( + (e.key === "Enter" || e.key === " ") && + e.target === e.currentTarget + ) { e.preventDefault(); onSelect(); } }} className={cn( - 'group w-full cursor-pointer rounded-lg px-3 py-2.5 text-left transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/50', - isSelected ? 'bg-surface-1 ring-1 ring-border/50' : 'hover:bg-surface-1/50', + "group w-full cursor-pointer rounded-lg px-3 py-2.5 text-left transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/50", + isSelected + ? "bg-surface-1 ring-1 ring-border/50" + : "hover:bg-surface-1/50", )} > {/* Header: type word + author · time + actions */}
- {typeLabel} + + {typeLabel} + {annotation.diffContext && ( diff )} - {annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)} + {annotation.author + ? `${annotation.author}${isMe ? " (me)" : ""} · ` + : ""} + {formatTimestamp(annotation.createdA)}
- {onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && ( - - )} + {onEdit && + annotation.type !== AnnotationType.DELETION && + !isEditing && ( + + )}
{/* File / line meta */} -
+
{fileName} · {lineRange}
@@ -569,10 +713,14 @@ const CodeAnnotationCard: React.FC<{ value={editText} onChange={(e) => setEditText(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) { + if ( + e.key === "Enter" && + (e.metaKey || e.ctrlKey) && + !e.nativeEvent.isComposing + ) { e.preventDefault(); handleSaveEdit(); - } else if (e.key === 'Escape') { + } else if (e.key === "Escape") { e.preventDefault(); handleCancelEdit(); } @@ -580,11 +728,21 @@ const CodeAnnotationCard: React.FC<{ placeholder="Add your comment..." aria-label="Annotation comment" className="w-full resize-none rounded-lg border border-border/50 bg-card px-2.5 py-2 text-base leading-relaxed text-foreground outline-none transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:ring-1 focus:ring-primary/20" - style={{ fieldSizing: 'content', minHeight: 44 } as React.CSSProperties} + style={ + { fieldSizing: "content", minHeight: 44 } as React.CSSProperties + } />
- - + +
) : ( @@ -600,7 +758,12 @@ const CodeAnnotationCard: React.FC<{ {annotation.images.map((img) => (
-
{img.name}
+
+ {img.name} +
))}
diff --git a/packages/ui/components/BlockRenderer.tsx b/packages/ui/components/BlockRenderer.tsx index 0ac5bfeae..f32e8938e 100644 --- a/packages/ui/components/BlockRenderer.tsx +++ b/packages/ui/components/BlockRenderer.tsx @@ -20,15 +20,28 @@ export const BlockRenderer: React.FC<{ githubRepo?: string; headingAnchorId?: string; onNavigateAnchor?: (hash: string) => void; -}> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, headingAnchorId, onNavigateAnchor }) => { +}> = ({ + block, + onOpenLinkedDoc, + onOpenCodeFile, + imageBaseDir, + onImageClick, + onToggleCheckbox, + checkboxOverrides, + orderedIndex, + githubRepo, + headingAnchorId, + onNavigateAnchor, +}) => { switch (block.type) { - case 'heading': { + case "heading": { const Tag = `h${block.level || 1}` as React.ElementType; - const styles = { - 1: 'text-2xl font-bold mb-4 mt-6 first:mt-0 tracking-tight', - 2: 'text-xl font-semibold mb-3 mt-8 text-foreground/90', - 3: 'text-base font-semibold mb-2 mt-6 text-foreground/80', - }[block.level || 1] || 'text-base font-semibold mb-2 mt-4'; + const styles = + { + 1: "text-2xl font-bold mb-6 mt-8 first:mt-0 tracking-tight", + 2: "text-xl font-semibold mb-4 mt-10 text-foreground/90", + 3: "text-base font-semibold mb-3 mt-8 text-foreground/80", + }[block.level || 1] || "text-base font-semibold mb-2 mt-4"; return ( - + ); } - case 'blockquote': { + case "blockquote": { if (block.alertKind) { return ( {paragraphs.map((para, i) => ( -

0 ? 'mt-2' : ''}> - +

0 ? "mt-2" : ""}> +

))} ); } - case 'list-item': { + case "list-item": { const indent = (block.level || 0) * 1.25; // 1.25rem per level const isCheckbox = block.checked !== undefined; const isChecked = checkboxOverrides?.has(block.id) ? checkboxOverrides.get(block.id)! : block.checked; const isInteractive = isCheckbox && !!onToggleCheckbox; - const textClass = `text-sm leading-relaxed ${isCheckbox && isChecked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`; - const inlineProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo, onNavigateAnchor }; + const textClass = `text-sm leading-relaxed ${isCheckbox && isChecked ? "text-muted-foreground line-through" : "text-foreground/90"}`; + const inlineProps = { + imageBaseDir, + onImageClick, + onOpenLinkedDoc, + onOpenCodeFile, + githubRepo, + onNavigateAnchor, + }; return (
@@ -95,19 +131,32 @@ export const BlockRenderer: React.FC<{ orderedIndex={orderedIndex} checked={isChecked} interactive={isInteractive} - onToggle={isInteractive ? () => onToggleCheckbox!(block.id, !isChecked) : undefined} + onToggle={ + isInteractive + ? () => onToggleCheckbox!(block.id, !isChecked) + : undefined + } textClassName={textClass} content={block.content} - renderInline={(text) => } + renderInline={(text) => ( + + )} />
); } - case 'code': - return {}} onLeave={() => {}} isHovered={false} />; + case "code": + return ( + {}} + onLeave={() => {}} + isHovered={false} + /> + ); - case 'table': + case "table": return ( ); - case 'hr': - return
; + case "hr": + return
; - case 'html': - return ; + case "html": + return ( + + ); - case 'directive': { - const kind = block.directiveKind || 'note'; + case "directive": { + const kind = block.directiveKind || "note"; return ( - +

); } diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 8bb4d5d9c..89f7f9015 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -1,19 +1,35 @@ -import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react'; -import { createPortal } from 'react-dom'; -import hljs from 'highlight.js'; -import { Block, Annotation, AnnotationType, EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types'; -import { Frontmatter, computeListIndices } from '../utils/parser'; -import { buildHeadingSlugMap } from '../utils/slugify'; -import { BlockRenderer } from './BlockRenderer'; -import { CodeBlock } from './blocks/CodeBlock'; -import { TableBlock } from './blocks/TableBlock'; -import { TableToolbar } from './blocks/TableToolbar'; -import { TablePopout } from './blocks/TablePopout'; -import { CodePathValidationContext } from './CodePathValidationContext'; -import { useValidatedCodePaths } from '../hooks/useValidatedCodePaths'; -import { ListMarker } from './ListMarker'; -import { AnnotationToolbar } from './AnnotationToolbar'; -import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker'; +import React, { + useRef, + useState, + useEffect, + useMemo, + forwardRef, + useImperativeHandle, + useCallback, +} from "react"; +import { createPortal } from "react-dom"; +import hljs from "highlight.js"; +import { + Block, + Annotation, + AnnotationType, + EditorMode, + type InputMethod, + type ImageAttachment, + type ActionsLabelMode, +} from "../types"; +import { Frontmatter, computeListIndices } from "../utils/parser"; +import { buildHeadingSlugMap } from "../utils/slugify"; +import { BlockRenderer } from "./BlockRenderer"; +import { CodeBlock } from "./blocks/CodeBlock"; +import { TableBlock } from "./blocks/TableBlock"; +import { TableToolbar } from "./blocks/TableToolbar"; +import { TablePopout } from "./blocks/TablePopout"; +import { CodePathValidationContext } from "./CodePathValidationContext"; +import { useValidatedCodePaths } from "../hooks/useValidatedCodePaths"; +import { ListMarker } from "./ListMarker"; +import { AnnotationToolbar } from "./AnnotationToolbar"; +import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker"; // Debug error boundary to catch silent toolbar crashes class ToolbarErrorBoundary extends React.Component< @@ -21,34 +37,52 @@ class ToolbarErrorBoundary extends React.Component< { error: Error | null } > { state = { error: null as Error | null }; - static getDerivedStateFromError(error: Error) { return { error }; } - componentDidCatch(error: Error) { console.error('AnnotationToolbar crashed:', error); } + static getDerivedStateFromError(error: Error) { + return { error }; + } + componentDidCatch(error: Error) { + console.error("AnnotationToolbar crashed:", error); + } render() { if (this.state.error) { - return
- Toolbar error: {this.state.error.message} -
; + return ( +
+ Toolbar error: {this.state.error.message} +
+ ); } return this.props.children; } } -import { CommentPopover, type CommentAskAIContext } from './CommentPopover'; -import { TaterSpriteSitting } from './TaterSpriteSitting'; -import { AttachmentsButton } from './AttachmentsButton'; -import { MessagesIcon } from './icons/MessagesIcon'; -import { GraphvizBlock } from './GraphvizBlock'; -import { MermaidBlock } from './MermaidBlock'; -import { getImageSrc } from './ImageThumbnail'; -import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages'; -import { getIdentity } from '../utils/identity'; -import { type QuickLabel } from '../utils/quickLabels'; -import { DocBadges } from './DocBadges'; -import { PinpointOverlay } from './PinpointOverlay'; -import { usePinpoint } from '../hooks/usePinpoint'; -import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter'; -import { useScrollViewport } from '../hooks/useScrollViewport'; -import { decodeAnchorHash } from '../utils/anchors'; +import { CommentPopover, type CommentAskAIContext } from "./CommentPopover"; +import { TaterSpriteSitting } from "./TaterSpriteSitting"; +import { AttachmentsButton } from "./AttachmentsButton"; +import { MessagesIcon } from "./icons/MessagesIcon"; +import { GraphvizBlock } from "./GraphvizBlock"; +import { MermaidBlock } from "./MermaidBlock"; +import { getImageSrc } from "./ImageThumbnail"; +import { isGraphvizLanguage, isMermaidLanguage } from "./diagramLanguages"; +import { getIdentity } from "../utils/identity"; +import { type QuickLabel } from "../utils/quickLabels"; +import { DocBadges } from "./DocBadges"; +import { PinpointOverlay } from "./PinpointOverlay"; +import { usePinpoint } from "../hooks/usePinpoint"; +import { useAnnotationHighlighter } from "../hooks/useAnnotationHighlighter"; +import { useScrollViewport } from "../hooks/useScrollViewport"; +import { decodeAnchorHash } from "../utils/anchors"; interface ViewerProps { blocks: Block[]; @@ -75,9 +109,18 @@ interface ViewerProps { * so out-of-tree relative references (e.g. `../foo.ts` in a linked doc) * resolve against the doc's own directory rather than only cwd. */ codePathBaseDir?: string; - linkedDocInfo?: { filepath: string; onBack: () => void; label?: string; backLabel?: string } | null; + linkedDocInfo?: { + filepath: string; + onBack: () => void; + label?: string; + backLabel?: string; + } | null; // Plan diff props - planDiffStats?: { additions: number; deletions: number; modifications: number } | null; + planDiffStats?: { + additions: number; + deletions: number; + modifications: number; + } | null; isPlanDiffActive?: boolean; onPlanDiffToggle?: () => void; hasPreviousVersion?: boolean; @@ -93,7 +136,11 @@ interface ViewerProps { * callers that don't measure plan-area width. */ actionsLabelMode?: ActionsLabelMode; - archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null; + archiveInfo?: { + status: "approved" | "denied" | "unknown"; + timestamp: string; + title: string; + } | null; /** Source attribution for HTML/URL annotations (e.g. URL or filename) */ sourceInfo?: string; /** @@ -118,7 +165,9 @@ export interface ViewerHandle { /** * Renders YAML frontmatter as a styled metadata card. */ -const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }) => { +const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ + frontmatter, +}) => { const entries = Object.entries(frontmatter); if (entries.length === 0) return null; @@ -127,12 +176,17 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }
{entries.map(([key, value]) => (
- {key}: + + {key}: + {Array.isArray(value) ? ( {value.map((v, i) => ( - + {v} ))} @@ -148,775 +202,986 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter } ); }; -export const Viewer = forwardRef(({ - blocks, - markdown, - frontmatter, - annotations, - onAddAnnotation, - onSelectAnnotation, - selectedAnnotationId, - mode, - inputMethod = 'drag', - taterMode, - globalAttachments = [], - onAddGlobalAttachment, - onRemoveGlobalAttachment, - repoInfo, - stickyActions = true, - gridEnabled = false, - planDiffStats, - isPlanDiffActive, - onPlanDiffToggle, - hasPreviousVersion, - showDemoBadge, - maxWidth, - onOpenLinkedDoc, - onOpenCodeFile, - linkedDocInfo, - imageBaseDir, - codePathBaseDir, - copyLabel, - actionsLabelMode = 'full', - archiveInfo, - sourceInfo, - messagePickerInfo, - onToggleCheckbox, - checkboxOverrides, - onAskAI, -}, ref) => { - const [copied, setCopied] = useState(false); - const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); - const [locationHash, setLocationHash] = useState(() => window.location.hash); - const globalCommentButtonRef = useRef(null); - - const handleCopyPlan = async () => { - try { - await navigator.clipboard.writeText(markdown); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (e) { - console.error('Failed to copy:', e); - } - }; - const containerRef = useRef(null); - // Per-doc heading slug map with dedup — computed once per blocks array so - // anchor ids stay stable across re-renders and duplicate heading texts get - // `-1`/`-2`/... suffixes rather than colliding on the same id. - const headingSlugMap = useMemo(() => buildHeadingSlugMap(blocks), [blocks]); - const isTouchDevice = useMemo(() => window.matchMedia('(pointer: coarse)').matches, []); - const [hoveredCodeBlock, setHoveredCodeBlock] = useState<{ block: Block; element: HTMLElement } | null>(null); - const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = useState(false); - const [hoveredTable, setHoveredTable] = useState<{ block: Block; element: HTMLElement } | null>(null); - const [isTableToolbarExiting, setIsTableToolbarExiting] = useState(false); - const tableHoverTimeoutRef = useRef(null); - const [popoutTable, setPopoutTable] = useState(null); - // Viewer-specific comment popover state (global comments + code blocks) - const [viewerCommentPopover, setViewerCommentPopover] = useState<{ - anchorEl: HTMLElement; - contextText: string; - selectedText?: string; - initialText?: string; - isGlobal: boolean; - codeBlock?: { block: Block; element: HTMLElement }; - } | null>(null); - // Viewer-specific quick label state (code blocks) - const [codeBlockQuickLabelPicker, setCodeBlockQuickLabelPicker] = useState<{ - anchorEl: HTMLElement; - codeBlock: { block: Block; element: HTMLElement }; - } | null>(null); - const hoverTimeoutRef = useRef(null); - const stickySentinelRef = useRef(null); - const lastAutoScrolledHashRef = useRef(null); - const [isStuck, setIsStuck] = useState(false); - - // Shared annotation infrastructure via hook - const { - highlighterRef, - toolbarState, - commentPopover: hookCommentPopover, - quickLabelPicker: hookQuickLabelPicker, - handleAnnotate, - handleQuickLabel, - handleToolbarClose, - handleRequestComment, - handleCommentSubmit: hookCommentSubmit, - handleCommentClose: hookCommentClose, - handleFloatingQuickLabel: hookFloatingQuickLabel, - handleQuickLabelPickerDismiss: hookQuickLabelPickerDismiss, - removeHighlight: hookRemoveHighlight, - clearAllHighlights, - applyAnnotations, - } = useAnnotationHighlighter({ - containerRef, - annotations, - onAddAnnotation, - onSelectAnnotation, - selectedAnnotationId, - mode, - }); - - // Refs for code block annotation path - const onAddAnnotationRef = useRef(onAddAnnotation); - useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]); - const modeRef = useRef(mode); - useEffect(() => { modeRef.current = mode; }, [mode]); - - // Pinpoint mode: hover + click to select elements - const handlePinpointCodeBlockClick = useCallback((blockId: string, element: HTMLElement) => { - const codeEl = element.querySelector('code'); - if (!codeEl) return; - // In pinpoint mode, apply code block annotation based on current editor mode - if (modeRef.current === 'redline') { - applyCodeBlockAnnotation(blockId, codeEl, AnnotationType.DELETION); - } else if (modeRef.current === 'quickLabel') { - setCodeBlockQuickLabelPicker({ - anchorEl: element, - codeBlock: { block: blocks.find(b => b.id === blockId)!, element }, - }); - } else { - // Show comment popover anchored to the code block - setViewerCommentPopover({ - anchorEl: element, - contextText: (codeEl.textContent || '').slice(0, 80), - selectedText: codeEl.textContent || '', - isGlobal: false, - codeBlock: { block: blocks.find(b => b.id === blockId)!, element }, - }); - } - }, [blocks]); - - const { hoverTarget } = usePinpoint({ - containerRef, - highlighterRef, - inputMethod, - enabled: !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false), - onCodeBlockClick: handlePinpointCodeBlockClick, - }); - - // Suppress native context menu on touch devices (prevents cut/copy/paste overlay on mobile) - useEffect(() => { - const container = containerRef.current; - if (!container || !isTouchDevice) return; - - const handleContextMenu = (e: Event) => { - e.preventDefault(); +export const Viewer = forwardRef( + ( + { + blocks, + markdown, + frontmatter, + annotations, + onAddAnnotation, + onSelectAnnotation, + selectedAnnotationId, + mode, + inputMethod = "drag", + taterMode, + globalAttachments = [], + onAddGlobalAttachment, + onRemoveGlobalAttachment, + repoInfo, + stickyActions = true, + gridEnabled = false, + planDiffStats, + isPlanDiffActive, + onPlanDiffToggle, + hasPreviousVersion, + showDemoBadge, + maxWidth, + onOpenLinkedDoc, + onOpenCodeFile, + linkedDocInfo, + imageBaseDir, + codePathBaseDir, + copyLabel, + actionsLabelMode = "full", + archiveInfo, + sourceInfo, + messagePickerInfo, + onToggleCheckbox, + checkboxOverrides, + onAskAI, + }, + ref, + ) => { + const [copied, setCopied] = useState(false); + const [lightbox, setLightbox] = useState<{ + src: string; + alt: string; + } | null>(null); + const [locationHash, setLocationHash] = useState( + () => window.location.hash, + ); + const globalCommentButtonRef = useRef(null); + + const handleCopyPlan = async () => { + try { + await navigator.clipboard.writeText(markdown); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + console.error("Failed to copy:", e); + } }; - - container.addEventListener('contextmenu', handleContextMenu); - return () => container.removeEventListener('contextmenu', handleContextMenu); - }, []); - - // Detect when sticky action bar is "stuck" to show card background. - // The IntersectionObserver root must be the actual scroll element — the - // OverlayScrollArea viewport — not the
host, which doesn't scroll. - const stickyScrollViewport = useScrollViewport(); - useEffect(() => { - if (!stickyActions || !stickySentinelRef.current || !stickyScrollViewport) return; - const observer = new IntersectionObserver( - ([entry]) => setIsStuck(!entry.isIntersecting), - { root: stickyScrollViewport, threshold: 0 } + const containerRef = useRef(null); + // Per-doc heading slug map with dedup — computed once per blocks array so + // anchor ids stay stable across re-renders and duplicate heading texts get + // `-1`/`-2`/... suffixes rather than colliding on the same id. + const headingSlugMap = useMemo(() => buildHeadingSlugMap(blocks), [blocks]); + const isTouchDevice = useMemo( + () => window.matchMedia("(pointer: coarse)").matches, + [], ); - observer.observe(stickySentinelRef.current); - return () => observer.disconnect(); - }, [stickyActions, stickyScrollViewport]); + const [hoveredCodeBlock, setHoveredCodeBlock] = useState<{ + block: Block; + element: HTMLElement; + } | null>(null); + const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = + useState(false); + const [hoveredTable, setHoveredTable] = useState<{ + block: Block; + element: HTMLElement; + } | null>(null); + const [isTableToolbarExiting, setIsTableToolbarExiting] = useState(false); + const tableHoverTimeoutRef = useRef(null); + const [popoutTable, setPopoutTable] = useState(null); + // Viewer-specific comment popover state (global comments + code blocks) + const [viewerCommentPopover, setViewerCommentPopover] = useState<{ + anchorEl: HTMLElement; + contextText: string; + selectedText?: string; + initialText?: string; + isGlobal: boolean; + codeBlock?: { block: Block; element: HTMLElement }; + } | null>(null); + // Viewer-specific quick label state (code blocks) + const [codeBlockQuickLabelPicker, setCodeBlockQuickLabelPicker] = useState<{ + anchorEl: HTMLElement; + codeBlock: { block: Block; element: HTMLElement }; + } | null>(null); + const hoverTimeoutRef = useRef(null); + const stickySentinelRef = useRef(null); + const lastAutoScrolledHashRef = useRef(null); + const [isStuck, setIsStuck] = useState(false); + + // Shared annotation infrastructure via hook + const { + highlighterRef, + toolbarState, + commentPopover: hookCommentPopover, + quickLabelPicker: hookQuickLabelPicker, + handleAnnotate, + handleQuickLabel, + handleToolbarClose, + handleRequestComment, + handleCommentSubmit: hookCommentSubmit, + handleCommentClose: hookCommentClose, + handleFloatingQuickLabel: hookFloatingQuickLabel, + handleQuickLabelPickerDismiss: hookQuickLabelPickerDismiss, + removeHighlight: hookRemoveHighlight, + clearAllHighlights, + applyAnnotations, + } = useAnnotationHighlighter({ + containerRef, + annotations, + onAddAnnotation, + onSelectAnnotation, + selectedAnnotationId, + mode, + }); - useEffect(() => { - const handleHashChange = () => { - lastAutoScrolledHashRef.current = null; - setLocationHash(window.location.hash); - }; + // Refs for code block annotation path + const onAddAnnotationRef = useRef(onAddAnnotation); + useEffect(() => { + onAddAnnotationRef.current = onAddAnnotation; + }, [onAddAnnotation]); + const modeRef = useRef(mode); + useEffect(() => { + modeRef.current = mode; + }, [mode]); + + // Pinpoint mode: hover + click to select elements + const handlePinpointCodeBlockClick = useCallback( + (blockId: string, element: HTMLElement) => { + const codeEl = element.querySelector("code"); + if (!codeEl) return; + // In pinpoint mode, apply code block annotation based on current editor mode + if (modeRef.current === "redline") { + applyCodeBlockAnnotation(blockId, codeEl, AnnotationType.DELETION); + } else if (modeRef.current === "quickLabel") { + setCodeBlockQuickLabelPicker({ + anchorEl: element, + codeBlock: { + block: blocks.find((b) => b.id === blockId)!, + element, + }, + }); + } else { + // Show comment popover anchored to the code block + setViewerCommentPopover({ + anchorEl: element, + contextText: (codeEl.textContent || "").slice(0, 80), + selectedText: codeEl.textContent || "", + isGlobal: false, + codeBlock: { + block: blocks.find((b) => b.id === blockId)!, + element, + }, + }); + } + }, + [blocks], + ); - window.addEventListener('hashchange', handleHashChange); - return () => window.removeEventListener('hashchange', handleHashChange); - }, []); - - const scrollToAnchor = useCallback((hash: string) => { - const anchor = decodeAnchorHash(hash); - if (!anchor) return false; - - const container = containerRef.current; - if (!container || !stickyScrollViewport) return false; - - const target = document.getElementById(anchor); - if (!target || !container.contains(target)) return false; - - const stickyActionsEl = container.querySelector('[data-sticky-actions]'); - const stickyTop = stickyActionsEl - ? Number.parseFloat(window.getComputedStyle(stickyActionsEl).top || '0') || 0 - : 0; - const headerOffset = stickyActionsEl - ? stickyActionsEl.getBoundingClientRect().height + stickyTop - : 0; - const containerRect = stickyScrollViewport.getBoundingClientRect(); - const targetRect = target.getBoundingClientRect(); - const relativeTop = targetRect.top - containerRect.top; - const offsetPosition = stickyScrollViewport.scrollTop + relativeTop - headerOffset; - - stickyScrollViewport.scrollTo({ - top: Math.max(0, offsetPosition), - behavior: 'smooth', + const { hoverTarget } = usePinpoint({ + containerRef, + highlighterRef, + inputMethod, + enabled: + !toolbarState && + !hookCommentPopover && + !viewerCommentPopover && + !hookQuickLabelPicker && + !codeBlockQuickLabelPicker && + !(isPlanDiffActive ?? false), + onCodeBlockClick: handlePinpointCodeBlockClick, }); - return true; - }, [stickyScrollViewport]); - useEffect(() => { - if (!stickyScrollViewport || !locationHash || lastAutoScrolledHashRef.current === locationHash) return; - const timer = window.setTimeout(() => { - if (scrollToAnchor(locationHash)) { - lastAutoScrolledHashRef.current = locationHash; - } - }, 0); - return () => window.clearTimeout(timer); - }, [blocks, locationHash, scrollToAnchor, stickyScrollViewport]); - - // Use the native copy event so clipboard writes are synchronous (Safari - // rejects the async navigator.clipboard API outside the user-gesture window). - // web-highlighter clears the DOM selection on mouseup, so the browser has - // nothing to copy by the time Cmd+C fires — we inject the captured text here. - useEffect(() => { - const handleCopy = (e: ClipboardEvent) => { - const tag = (e.target as HTMLElement)?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA') return; + // Suppress native context menu on touch devices (prevents cut/copy/paste overlay on mobile) + useEffect(() => { + const container = containerRef.current; + if (!container || !isTouchDevice) return; - if (toolbarState?.selectionText) { + const handleContextMenu = (e: Event) => { e.preventDefault(); - e.clipboardData?.setData('text/plain', toolbarState.selectionText); - } - }; + }; - document.addEventListener('copy', handleCopy); - return () => document.removeEventListener('copy', handleCopy); - }, [toolbarState]); - - // Imperative handle — delegates to hook, extends removeHighlight for code blocks - useImperativeHandle(ref, () => ({ - removeHighlight: (id: string) => { - // Code block annotations need syntax re-highlighting after removal. - // Must run BEFORE hookRemoveHighlight, which removes the elements. - const manualHighlights = containerRef.current?.querySelectorAll(`[data-bind-id="${id}"]`); - manualHighlights?.forEach(el => { - const parent = el.parentNode; - if (parent && parent.nodeName === 'CODE') { - const codeEl = parent as HTMLElement; - const plainText = el.textContent || ''; - el.remove(); - codeEl.textContent = plainText; - const block = blocks.find(b => b.id === codeEl.closest('[data-block-id]')?.getAttribute('data-block-id')); - codeEl.removeAttribute('data-highlighted'); - codeEl.className = `hljs font-mono${block?.language ? ` language-${block.language}` : ''}`; - hljs.highlightElement(codeEl); - } - }); + container.addEventListener("contextmenu", handleContextMenu); + return () => + container.removeEventListener("contextmenu", handleContextMenu); + }, []); + + // Detect when sticky action bar is "stuck" to show card background. + // The IntersectionObserver root must be the actual scroll element — the + // OverlayScrollArea viewport — not the
host, which doesn't scroll. + const stickyScrollViewport = useScrollViewport(); + useEffect(() => { + if (!stickyActions || !stickySentinelRef.current || !stickyScrollViewport) + return; + const observer = new IntersectionObserver( + ([entry]) => setIsStuck(!entry.isIntersecting), + { root: stickyScrollViewport, threshold: 0 }, + ); + observer.observe(stickySentinelRef.current); + return () => observer.disconnect(); + }, [stickyActions, stickyScrollViewport]); + + useEffect(() => { + const handleHashChange = () => { + lastAutoScrolledHashRef.current = null; + setLocationHash(window.location.hash); + }; - hookRemoveHighlight(id); - }, - clearAllHighlights, - applySharedAnnotations: applyAnnotations, - }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks]); - - // --- Viewer-specific: code block annotation --- - - const applyCodeBlockAnnotation = ( - blockId: string, - codeEl: Element, - type: AnnotationType, - text?: string, - images?: ImageAttachment[], - isQuickLabel?: boolean, - quickLabelTip?: string, - ) => { - const id = `codeblock-${Date.now()}`; - const codeText = codeEl.textContent || ''; - - const wrapper = document.createElement('mark'); - wrapper.className = `annotation-highlight ${type === AnnotationType.DELETION ? 'deletion' : type === AnnotationType.COMMENT ? 'comment' : ''}`.trim(); - wrapper.dataset.bindId = id; - wrapper.textContent = codeText; - - codeEl.innerHTML = ''; - codeEl.appendChild(wrapper); - - const newAnnotation: Annotation = { - id, - blockId, - startOffset: 0, - endOffset: codeText.length, - type, - text, - originalText: codeText, - createdA: Date.now(), - author: getIdentity(), - images, - ...(isQuickLabel ? { isQuickLabel: true } : {}), - ...(quickLabelTip ? { quickLabelTip } : {}), - }; + window.addEventListener("hashchange", handleHashChange); + return () => window.removeEventListener("hashchange", handleHashChange); + }, []); + + const scrollToAnchor = useCallback( + (hash: string) => { + const anchor = decodeAnchorHash(hash); + if (!anchor) return false; + + const container = containerRef.current; + if (!container || !stickyScrollViewport) return false; + + const target = document.getElementById(anchor); + if (!target || !container.contains(target)) return false; + + const stickyActionsEl = container.querySelector( + "[data-sticky-actions]", + ); + const stickyTop = stickyActionsEl + ? Number.parseFloat( + window.getComputedStyle(stickyActionsEl).top || "0", + ) || 0 + : 0; + const headerOffset = stickyActionsEl + ? stickyActionsEl.getBoundingClientRect().height + stickyTop + : 0; + const containerRect = stickyScrollViewport.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const relativeTop = targetRect.top - containerRect.top; + const offsetPosition = + stickyScrollViewport.scrollTop + relativeTop - headerOffset; + + stickyScrollViewport.scrollTo({ + top: Math.max(0, offsetPosition), + behavior: "smooth", + }); + return true; + }, + [stickyScrollViewport], + ); + + useEffect(() => { + if ( + !stickyScrollViewport || + !locationHash || + lastAutoScrolledHashRef.current === locationHash + ) + return; + const timer = window.setTimeout(() => { + if (scrollToAnchor(locationHash)) { + lastAutoScrolledHashRef.current = locationHash; + } + }, 0); + return () => window.clearTimeout(timer); + }, [blocks, locationHash, scrollToAnchor, stickyScrollViewport]); + + // Use the native copy event so clipboard writes are synchronous (Safari + // rejects the async navigator.clipboard API outside the user-gesture window). + // web-highlighter clears the DOM selection on mouseup, so the browser has + // nothing to copy by the time Cmd+C fires — we inject the captured text here. + useEffect(() => { + const handleCopy = (e: ClipboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA") return; + + if (toolbarState?.selectionText) { + e.preventDefault(); + e.clipboardData?.setData("text/plain", toolbarState.selectionText); + } + }; - onAddAnnotationRef.current(newAnnotation); - window.getSelection()?.removeAllRanges(); - }; - - const handleCodeBlockAnnotate = (type: AnnotationType) => { - if (!hoveredCodeBlock) return; - const codeEl = hoveredCodeBlock.element.querySelector('code'); - if (!codeEl) return; - applyCodeBlockAnnotation(hoveredCodeBlock.block.id, codeEl, type); - setHoveredCodeBlock(null); - }; - - const handleCodeBlockQuickLabel = (label: QuickLabel) => { - if (!hoveredCodeBlock) return; - const codeEl = hoveredCodeBlock.element.querySelector('code'); - if (!codeEl) return; - applyCodeBlockAnnotation( - hoveredCodeBlock.block.id, codeEl, AnnotationType.COMMENT, - `${label.emoji} ${label.text}`, undefined, true, label.tip + document.addEventListener("copy", handleCopy); + return () => document.removeEventListener("copy", handleCopy); + }, [toolbarState]); + + // Imperative handle — delegates to hook, extends removeHighlight for code blocks + useImperativeHandle( + ref, + () => ({ + removeHighlight: (id: string) => { + // Code block annotations need syntax re-highlighting after removal. + // Must run BEFORE hookRemoveHighlight, which removes the elements. + const manualHighlights = containerRef.current?.querySelectorAll( + `[data-bind-id="${id}"]`, + ); + manualHighlights?.forEach((el) => { + const parent = el.parentNode; + if (parent && parent.nodeName === "CODE") { + const codeEl = parent as HTMLElement; + const plainText = el.textContent || ""; + el.remove(); + codeEl.textContent = plainText; + const block = blocks.find( + (b) => + b.id === + codeEl + .closest("[data-block-id]") + ?.getAttribute("data-block-id"), + ); + codeEl.removeAttribute("data-highlighted"); + codeEl.className = `hljs font-mono${block?.language ? ` language-${block.language}` : ""}`; + hljs.highlightElement(codeEl); + } + }); + + hookRemoveHighlight(id); + }, + clearAllHighlights, + applySharedAnnotations: applyAnnotations, + }), + [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks], ); - setHoveredCodeBlock(null); - }; - - const handleCodeBlockToolbarClose = () => { - setHoveredCodeBlock(null); - }; - - // Viewer-specific comment popover handlers (code blocks + global comments) - - const handleCodeBlockRequestComment = (initialChar?: string) => { - if (!hoveredCodeBlock) return; - const codeText = hoveredCodeBlock.element.querySelector('code')?.textContent || ''; - setViewerCommentPopover({ - anchorEl: hoveredCodeBlock.element, - contextText: codeText.slice(0, 80), - selectedText: codeText, - initialText: initialChar, - isGlobal: false, - codeBlock: hoveredCodeBlock, - }); - setHoveredCodeBlock(null); - }; - const handleViewerCommentSubmit = (text: string, images?: ImageAttachment[]) => { - if (!viewerCommentPopover) return; + // --- Viewer-specific: code block annotation --- + + const applyCodeBlockAnnotation = ( + blockId: string, + codeEl: Element, + type: AnnotationType, + text?: string, + images?: ImageAttachment[], + isQuickLabel?: boolean, + quickLabelTip?: string, + ) => { + const id = `codeblock-${Date.now()}`; + const codeText = codeEl.textContent || ""; + + const wrapper = document.createElement("mark"); + wrapper.className = + `annotation-highlight ${type === AnnotationType.DELETION ? "deletion" : type === AnnotationType.COMMENT ? "comment" : ""}`.trim(); + wrapper.dataset.bindId = id; + wrapper.textContent = codeText; + + codeEl.innerHTML = ""; + codeEl.appendChild(wrapper); - if (viewerCommentPopover.isGlobal) { const newAnnotation: Annotation = { - id: `global-${Date.now()}`, - blockId: '', + id, + blockId, startOffset: 0, - endOffset: 0, - type: AnnotationType.GLOBAL_COMMENT, - text: text.trim(), - originalText: '', + endOffset: codeText.length, + type, + text, + originalText: codeText, createdA: Date.now(), author: getIdentity(), images, + ...(isQuickLabel ? { isQuickLabel: true } : {}), + ...(quickLabelTip ? { quickLabelTip } : {}), }; - onAddAnnotation(newAnnotation); - } else if (viewerCommentPopover.codeBlock) { - const codeEl = viewerCommentPopover.codeBlock.element.querySelector('code'); - if (codeEl) { - applyCodeBlockAnnotation(viewerCommentPopover.codeBlock.block.id, codeEl, AnnotationType.COMMENT, text, images); + + onAddAnnotationRef.current(newAnnotation); + window.getSelection()?.removeAllRanges(); + }; + + const handleCodeBlockAnnotate = (type: AnnotationType) => { + if (!hoveredCodeBlock) return; + const codeEl = hoveredCodeBlock.element.querySelector("code"); + if (!codeEl) return; + applyCodeBlockAnnotation(hoveredCodeBlock.block.id, codeEl, type); + setHoveredCodeBlock(null); + }; + + const handleCodeBlockQuickLabel = (label: QuickLabel) => { + if (!hoveredCodeBlock) return; + const codeEl = hoveredCodeBlock.element.querySelector("code"); + if (!codeEl) return; + applyCodeBlockAnnotation( + hoveredCodeBlock.block.id, + codeEl, + AnnotationType.COMMENT, + `${label.emoji} ${label.text}`, + undefined, + true, + label.tip, + ); + setHoveredCodeBlock(null); + }; + + const handleCodeBlockToolbarClose = () => { + setHoveredCodeBlock(null); + }; + + // Viewer-specific comment popover handlers (code blocks + global comments) + + const handleCodeBlockRequestComment = (initialChar?: string) => { + if (!hoveredCodeBlock) return; + const codeText = + hoveredCodeBlock.element.querySelector("code")?.textContent || ""; + setViewerCommentPopover({ + anchorEl: hoveredCodeBlock.element, + contextText: codeText.slice(0, 80), + selectedText: codeText, + initialText: initialChar, + isGlobal: false, + codeBlock: hoveredCodeBlock, + }); + setHoveredCodeBlock(null); + }; + + const handleViewerCommentSubmit = ( + text: string, + images?: ImageAttachment[], + ) => { + if (!viewerCommentPopover) return; + + if (viewerCommentPopover.isGlobal) { + const newAnnotation: Annotation = { + id: `global-${Date.now()}`, + blockId: "", + startOffset: 0, + endOffset: 0, + type: AnnotationType.GLOBAL_COMMENT, + text: text.trim(), + originalText: "", + createdA: Date.now(), + author: getIdentity(), + images, + }; + onAddAnnotation(newAnnotation); + } else if (viewerCommentPopover.codeBlock) { + const codeEl = + viewerCommentPopover.codeBlock.element.querySelector("code"); + if (codeEl) { + applyCodeBlockAnnotation( + viewerCommentPopover.codeBlock.block.id, + codeEl, + AnnotationType.COMMENT, + text, + images, + ); + } } - } - setViewerCommentPopover(null); - }; + setViewerCommentPopover(null); + }; - const handleViewerCommentClose = useCallback(() => { - setViewerCommentPopover(null); - }, []); + const handleViewerCommentClose = useCallback(() => { + setViewerCommentPopover(null); + }, []); + + const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir); + + return ( + +
+ {taterMode && } +
+ {/* Repo info + plan diff badge + demo badge + linked doc badge + archive badge - top left */} + {(repoInfo || + hasPreviousVersion || + showDemoBadge || + linkedDocInfo || + archiveInfo || + sourceInfo) && ( +
+ +
+ )} - const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir); + {/* Sentinel for sticky detection */} + {stickyActions && ( +
+ + {/* Image lightbox */} + {lightbox && + createPortal( + setLightbox(null)} + />, + document.body, + )} +
+
+ ); + }, +); /** Simple lightbox overlay for enlarged image viewing. */ -const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }> = ({ src, alt, onClose }) => { +const ImageLightbox: React.FC<{ + src: string; + alt: string; + onClose: () => void; +}> = ({ src, alt, onClose }) => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); + if (e.key === "Escape") onClose(); }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); }, [onClose]); return ( @@ -931,37 +1196,38 @@ const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }> onClick={(e) => e.stopPropagation()} /> {alt && ( -
{alt}
+
+ {alt} +
)}
); }; - - - - /** Groups consecutive list-item blocks so they can share a pinpoint hover wrapper. */ type RenderGroup = - | { type: 'single'; block: Block } - | { type: 'list-group'; blocks: Block[]; key: string }; + | { type: "single"; block: Block } + | { type: "list-group"; blocks: Block[]; key: string }; function groupBlocks(blocks: Block[]): RenderGroup[] { const groups: RenderGroup[] = []; let i = 0; while (i < blocks.length) { - if (blocks[i].type === 'list-item') { + if (blocks[i].type === "list-item") { const listBlocks: Block[] = []; - while (i < blocks.length && blocks[i].type === 'list-item') { + while (i < blocks.length && blocks[i].type === "list-item") { listBlocks.push(blocks[i]); i++; } - groups.push({ type: 'list-group', blocks: listBlocks, key: `list-${listBlocks[0].id}` }); + groups.push({ + type: "list-group", + blocks: listBlocks, + key: `list-${listBlocks[0].id}`, + }); } else { - groups.push({ type: 'single', block: blocks[i] }); + groups.push({ type: "single", block: blocks[i] }); i++; } } return groups; } -