diff --git a/apps/hook/server/clearContextSetting.test.ts b/apps/hook/server/clearContextSetting.test.ts new file mode 100644 index 00000000..6e427893 --- /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 00000000..7cb1f62f --- /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/hookDecision.test.ts b/apps/hook/server/hookDecision.test.ts new file mode 100644 index 00000000..63e678c7 --- /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 00000000..1f613f1c --- /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 9287d2fe..83bc711c 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -110,6 +110,8 @@ import { isTopLevelHelpInvocation, isVersionInvocation, } from "./cli"; +import { ensureClearContextSettingEnabled } from "./clearContextSetting"; +import { formatClaudePlanHookOutput, normalizeClaudeHookEventName } from "./hookDecision"; import path from "path"; import { tmpdir } from "os"; @@ -1202,6 +1204,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 +1223,7 @@ if (args[0] === "sessions") { plan: planContent, origin: isGemini ? "gemini-cli" : detectedOrigin, permissionMode, + toolName, sharingEnabled, shareBaseUrl, pasteApiUrl, @@ -1264,45 +1273,27 @@ if (args[0] === "sessions") { ); } } else { - // Claude Code: PermissionRequest hook decision - if (result.approved) { - const updatedPermissions = []; - if (result.permissionMode) { - updatedPermissions.push({ - type: "setMode", - mode: result.permissionMode, - destination: "session", - }); - } - - console.log( - JSON.stringify({ - 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", - }), - }, - }, + const hookEventName = normalizeClaudeHookEventName(event.hook_event_name); + const nativeClearEnabled = + result.approved && + result.deferToNativeForClear && + hookEventName === "PreToolUse" && + toolName === "ExitPlanMode" + ? await ensureClearContextSettingEnabled() + : false; + + console.log( + JSON.stringify( + formatClaudePlanHookOutput({ + result, + hookEventName, + toolName, + detectedOrigin, + nativeClearEnabled, + planFilename, }) - ); - } + ) + ); } process.exit(0); diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index c1d390ed..a47e89a8 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 { @@ -471,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()) { @@ -499,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, diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index 12845ae7..f2754a6d 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 bbfafaff..7af6306d 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -4,13 +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, - startReviewServer, -} from "./server"; +import { getGitContext, runGitDiff, startPlanReviewServer, startReviewServer } from "./server"; const tempDirs: string[] = []; const originalCwd = process.cwd(); @@ -132,7 +126,44 @@ 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-"); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 06ba5275..a894b21d 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 { @@ -50,12 +52,17 @@ 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; savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; } export interface PlanServerResult { @@ -238,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, }, @@ -369,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 { @@ -377,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; @@ -438,8 +447,19 @@ export async function startPlanReviewServer(options: { savedPath, agentSwitch, permissionMode: effectivePermissionMode, + 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/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 98fa2dea..fbd710f6 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"; 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 00000000..65ecad6f --- /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); } +``` diff --git a/packages/editor/App.clearContextBanner.test.ts b/packages/editor/App.clearContextBanner.test.ts new file mode 100644 index 00000000..443eb177 --- /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 c48b7eb2..5cf5cb90 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'; @@ -75,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, shouldEnableNativeClearBeforeApprove, type ApprovalOverride } from './approvalBody'; const USE_DIFF_DEMO = import.meta.env.VITE_DIFF_DEMO === '1' || import.meta.env.VITE_DIFF_DEMO === 'true'; @@ -148,6 +152,8 @@ const App: React.FC = () => { }, []); const [isApiMode, setIsApiMode] = useState(false); const [origin, setOrigin] = useState(null); + const [pendingToolName, setPendingToolName] = useState(); + const [pendingApprovalOverride, setPendingApprovalOverride] = useState({}); const [gitUser, setGitUser] = useState(); const [isWSL, setIsWSL] = useState(false); const [globalAttachments, setGlobalAttachments] = useState([]); @@ -777,6 +783,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 @@ -784,7 +793,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); @@ -941,7 +951,7 @@ const App: React.FC = () => { }; // API mode handlers - const handleApprove = async () => { + const handleApprove = async (override: ApprovalOverride = {}) => { setIsSubmitting(true); try { const obsidianSettings = getObsidianSettings(); @@ -952,24 +962,31 @@ 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; + let approvalOverride = override; + if (shouldEnableNativeClearBeforeApprove({ origin, permissionMode, toolName: pendingToolName, override })) { + try { + const response = await fetch('/api/enable-clear-context', { method: 'POST' }); + if (!response.ok) { + throw new Error(`Unable to enable native clear-context: ${response.status}`); + } + } catch { + approvalOverride = { + permissionMode: 'bypassPermissions', + clearContextNudge: true, + }; + toast.warning('Native clear-on-accept unavailable; approving with a /clear reminder instead.'); + } } 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: approvalOverride, + effectiveAgent, + planSaveSettings, + toolName: pendingToolName, + }); const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings); if (obsidianSettings.enabled && effectiveVaultPath) { @@ -1040,6 +1057,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); @@ -1620,57 +1670,233 @@ 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 && ( @@ -2016,7 +2242,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.} @@ -2134,6 +2362,7 @@ const App: React.FC = () => { {/* Update notification */} + {/* 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 saved bypass clear reminder mode to hook approval with clear reminder on Claude Code ExitPlanMode', () => { + 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', + 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 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 reminder fallback 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', + 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', + toolName: 'ExitPlanMode', + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); + + test('forwards deferToNativeForClear 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 or native clear for OpenCode approvals', () => { + expect(buildApprovalRequestBody({ + origin: 'opencode', + permissionMode: 'bypassPermissionsClearReminder', + effectiveAgent: 'build', + toolName: 'ExitPlanMode', + override: { + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + agentSwitch: 'build', + planSave: { enabled: true }, + }); + }); + + test('does not forward deferToNativeForClear or native clear for Gemini origin', () => { + expect(buildApprovalRequestBody({ + origin: 'gemini-cli', + permissionMode: 'bypassPermissionsClearReminder', + toolName: 'ExitPlanMode', + override: { + deferToNativeForClear: true, + }, + planSaveSettings: { enabled: true }, + })).toEqual({ + planSave: { enabled: true }, + }); + }); +}); diff --git a/packages/editor/approvalBody.ts b/packages/editor/approvalBody.ts new file mode 100644 index 00000000..ed473793 --- /dev/null +++ b/packages/editor/approvalBody.ts @@ -0,0 +1,70 @@ +import type { Origin } from '@plannotator/shared/agents'; +import type { PermissionMode } from '@plannotator/ui/utils/permissionMode'; + +export type ApprovalOverride = { + permissionMode?: PermissionMode; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; +}; + +export interface ApprovalRequestBody { + obsidian?: object; + bear?: object; + octarine?: object; + feedback?: string; + agentSwitch?: string; + planSave?: { enabled: boolean; customPath?: string }; + permissionMode?: string; + clearContextNudge?: boolean; + 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; + override?: ApprovalOverride; + effectiveAgent?: string; + planSaveSettings: { enabled: boolean; customPath?: string | null }; + toolName?: string; +}): ApprovalRequestBody { + const { origin, permissionMode, override = {}, effectiveAgent, planSaveSettings, toolName } = options; + const body: ApprovalRequestBody = {}; + + if (origin === 'claude-code') { + const effectivePermissionMode = override.permissionMode ?? permissionMode; + const wantsClearContext = effectivePermissionMode === 'bypassPermissionsClearReminder'; + const useNativeClear = shouldEnableNativeClearBeforeApprove({ origin, permissionMode, toolName, override }); + + body.permissionMode = wantsClearContext ? 'bypassPermissions' : effectivePermissionMode; + + if (useNativeClear) { + body.deferToNativeForClear = true; + } else if (override.clearContextNudge || wantsClearContext) { + 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/editor/components/AppHeader.tsx b/packages/editor/components/AppHeader.tsx index ad3ca9b7..8c44d7f1 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/server/index.ts b/packages/server/index.ts index 8b1356a4..df664bc7 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 { @@ -60,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 { @@ -71,6 +78,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) */ @@ -101,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; @@ -112,6 +123,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 @@ -125,7 +194,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(); @@ -172,6 +241,8 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }) => void; let decisionPromise: Promise<{ approved: boolean; @@ -179,6 +250,8 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + clearContextNudge?: boolean; + deferToNativeForClear?: boolean; }>; if (mode !== "archive") { @@ -284,7 +357,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 @@ -310,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, }, @@ -455,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 { @@ -466,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") @@ -483,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; @@ -528,10 +613,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 }); + 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/ApproveDropdown.test.tsx b/packages/ui/components/ApproveDropdown.test.tsx new file mode 100644 index 00000000..967f8e4b --- /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 ab9c48c7..9424696d 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 e2982315..48a4bdb6 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 80999537..f35711b9 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 requests bypassPermissions and emits a /clear reminder * - 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: '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', 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, }; } diff --git a/scripts/install.cmd b/scripts/install.cmd index a150f744..0594b5cd 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 843b1378..3cfeca2c 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 bb752556..e27d0bf1 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 86f17a14..d295ff32 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"); });