diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1acb700 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,13 @@ +name: CI + +on: + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: biomejs/setup-biome@v2 + - run: biome ci . diff --git a/CLAUDE.md b/CLAUDE.md index 4137090..40cb66e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,9 +30,11 @@ plannotator/ │ └── vite.config.ts ├── packages/ │ ├── server/ # Shared server implementation -│ │ ├── index.ts # startPlannotatorServer(), handleServerReady() -│ │ ├── review.ts # startReviewServer(), handleReviewServerReady() -│ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady() +│ │ ├── index.ts # startPlannotatorServer() +│ │ ├── review.ts # startReviewServer() +│ │ ├── annotate.ts # startAnnotateServer() +│ │ ├── shared-handlers.ts # handleServerReady(), image/upload/agents handlers +│ │ ├── reference-handlers.ts # doc/vault file/vault doc handlers, buildFileTree │ │ ├── storage.ts # Plan saving to disk (getPlanDir, savePlan, etc.) │ │ ├── share-url.ts # Server-side share URL generation for remote sessions │ │ ├── remote.ts # isRemoteSession(), getServerPort() diff --git a/apps/hook/dev-mock-api.ts b/apps/hook/dev-mock-api.ts index d707e93..52f7296 100644 --- a/apps/hook/dev-mock-api.ts +++ b/apps/hook/dev-mock-api.ts @@ -203,23 +203,27 @@ export function devMockApi(): Plugin { server.middlewares.use((req, res, next) => { if (req.url === '/api/plan') { res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - plan: undefined, // Let editor use its own PLAN_CONTENT - origin: 'claude-code', - previousPlan: PLAN_V2, - versionInfo: { version: 3, totalVersions: 3, project: 'demo' }, - sharingEnabled: true, - })); + res.end( + JSON.stringify({ + plan: undefined, // Let editor use its own PLAN_CONTENT + origin: 'claude-code', + previousPlan: PLAN_V2, + versionInfo: { version: 3, totalVersions: 3, project: 'demo' }, + sharingEnabled: true, + }), + ); return; } if (req.url === '/api/plan/versions') { res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - project: 'demo', - slug: 'implementation-plan-real-time-collab', - versions, - })); + res.end( + JSON.stringify({ + project: 'demo', + slug: 'implementation-plan-real-time-collab', + versions, + }), + ); return; } @@ -239,14 +243,18 @@ export function devMockApi(): Plugin { if (req.url === '/api/plan/history') { res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - project: 'demo', - plans: [{ - slug: 'implementation-plan-real-time-collab', - versions: 3, - lastModified: new Date(now - 60_000).toISOString(), - }], - })); + res.end( + JSON.stringify({ + project: 'demo', + plans: [ + { + slug: 'implementation-plan-real-time-collab', + versions: 3, + lastModified: new Date(now - 60_000).toISOString(), + }, + ], + }), + ); return; } diff --git a/apps/hook/index.tsx b/apps/hook/index.tsx index 6a8f679..ee05173 100644 --- a/apps/hook/index.tsx +++ b/apps/hook/index.tsx @@ -1,16 +1,16 @@ +import App from '@plannotator/editor'; import React from 'react'; import ReactDOM from 'react-dom/client'; -import App from '@plannotator/editor'; import '@plannotator/editor/styles'; const rootElement = document.getElementById('root'); if (!rootElement) { - throw new Error("Could not find root element to mount to"); + throw new Error('Could not find root element to mount to'); } const root = ReactDOM.createRoot(rootElement); root.render( - -); \ No newline at end of file + , +); diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 7f7a476..25046b5 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -23,35 +23,28 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { - startPlannotatorServer, - handleServerReady, -} from "@plannotator/server"; -import { - startReviewServer, - handleReviewServerReady, -} from "@plannotator/server/review"; -import { - startAnnotateServer, - handleAnnotateServerReady, -} from "@plannotator/server/annotate"; -import { getGitContext, runGitDiff } from "@plannotator/server/git"; -import { writeRemoteShareLink } from "@plannotator/server/share-url"; +import { handleServerReady, startPlannotatorServer } from '@plannotator/server'; +import { handleAnnotateServerReady, startAnnotateServer } from '@plannotator/server/annotate'; +import { getGitContext, runGitDiff } from '@plannotator/server/git'; +import { handleReviewServerReady, startReviewServer } from '@plannotator/server/review'; +import { writeRemoteShareLink } from '@plannotator/server/share-url'; // Embed the built HTML at compile time -// @ts-ignore - Bun import attribute for text -import planHtml from "../dist/index.html" with { type: "text" }; +// @ts-expect-error - Bun import attribute for text +import planHtml from '../dist/index.html' with { type: 'text' }; + const planHtmlContent = planHtml as unknown as string; -// @ts-ignore - Bun import attribute for text -import reviewHtml from "../dist/review.html" with { type: "text" }; +// @ts-expect-error - Bun import attribute for text +import reviewHtml from '../dist/review.html' with { type: 'text' }; + const reviewHtmlContent = reviewHtml as unknown as string; // Check for subcommand const args = process.argv.slice(2); // Check if URL sharing is enabled (default: true) -const sharingEnabled = process.env.PLANNOTATOR_SHARE !== "disabled"; +const sharingEnabled = process.env.PLANNOTATOR_SHARE !== 'disabled'; // Custom share portal URL for self-hosting const shareBaseUrl = process.env.PLANNOTATOR_SHARE_URL || undefined; @@ -59,7 +52,7 @@ const shareBaseUrl = process.env.PLANNOTATOR_SHARE_URL || undefined; // Paste service URL for short URL sharing const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined; -if (args[0] === "review") { +if (args[0] === 'review') { // ============================================ // CODE REVIEW MODE // ============================================ @@ -68,18 +61,19 @@ if (args[0] === "review") { const gitContext = await getGitContext(); // Run git diff HEAD (uncommitted changes - default) - const { patch: rawPatch, label: gitRef, error: diffError } = await runGitDiff( - "uncommitted", - gitContext.defaultBranch - ); + const { + patch: rawPatch, + label: gitRef, + error: diffError, + } = await runGitDiff('uncommitted', gitContext.defaultBranch); // Start review server (even if empty - user can switch diff types) const server = await startReviewServer({ rawPatch, gitRef, error: diffError, - origin: "claude-code", - diffType: "uncommitted", + origin: 'claude-code', + diffType: 'uncommitted', gitContext, sharingEnabled, shareBaseUrl, @@ -88,7 +82,9 @@ if (args[0] === "review") { 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( + () => {}, + ); } }, }); @@ -103,22 +99,21 @@ if (args[0] === "review") { server.stop(); // Output feedback (captured by slash command) - console.log(result.feedback || "No feedback provided."); + console.log(result.feedback || 'No feedback provided.'); process.exit(0); - -} else if (args[0] === "annotate") { +} else if (args[0] === 'annotate') { // ============================================ // ANNOTATE MODE // ============================================ const filePath = args[1]; if (!filePath) { - console.error("Usage: plannotator annotate "); + console.error('Usage: plannotator annotate '); process.exit(1); } // Resolve to absolute path - const path = await import("path"); + const path = await import('node:path'); const absolutePath = path.resolve(filePath); // Read the markdown file @@ -133,7 +128,7 @@ if (args[0] === "review") { const server = await startAnnotateServer({ markdown, filePath: absolutePath, - origin: "claude-code", + origin: 'claude-code', sharingEnabled, shareBaseUrl, htmlContent: planHtmlContent, @@ -141,7 +136,9 @@ if (args[0] === "review") { handleAnnotateServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {}); + await writeRemoteShareLink(markdown, shareBaseUrl, 'annotate', 'document only').catch( + () => {}, + ); } }, }); @@ -156,9 +153,8 @@ if (args[0] === "review") { server.stop(); // Output feedback (captured by slash command) - console.log(result.feedback || "No feedback provided."); + console.log(result.feedback || 'No feedback provided.'); process.exit(0); - } else { // ============================================ // PLAN REVIEW MODE (default) @@ -167,26 +163,26 @@ if (args[0] === "review") { // Read hook event from stdin const eventJson = await Bun.stdin.text(); - let planContent = ""; - let permissionMode = "default"; + let planContent = ''; + let permissionMode = 'default'; try { const event = JSON.parse(eventJson); - planContent = event.tool_input?.plan || ""; - permissionMode = event.permission_mode || "default"; + planContent = event.tool_input?.plan || ''; + permissionMode = event.permission_mode || 'default'; } catch { - console.error("Failed to parse hook event from stdin"); + console.error('Failed to parse hook event from stdin'); process.exit(1); } if (!planContent) { - console.error("No plan content in hook event"); + console.error('No plan content in hook event'); process.exit(1); } // Start the plan review server const server = await startPlannotatorServer({ plan: planContent, - origin: "claude-code", + origin: 'claude-code', permissionMode, sharingEnabled, shareBaseUrl, @@ -196,7 +192,9 @@ if (args[0] === "review") { 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( + () => {}, + ); } }, }); @@ -216,34 +214,34 @@ if (args[0] === "review") { const updatedPermissions = []; if (result.permissionMode) { updatedPermissions.push({ - type: "setMode", + type: 'setMode', mode: result.permissionMode, - destination: "session", + destination: 'session', }); } console.log( JSON.stringify({ hookSpecificOutput: { - hookEventName: "PermissionRequest", + hookEventName: 'PermissionRequest', decision: { - behavior: "allow", + behavior: 'allow', ...(updatedPermissions.length > 0 && { updatedPermissions }), }, }, - }) + }), ); } else { console.log( JSON.stringify({ hookSpecificOutput: { - hookEventName: "PermissionRequest", + hookEventName: 'PermissionRequest', decision: { - behavior: "deny", - message: result.feedback || "Plan changes requested", + behavior: 'deny', + message: result.feedback || 'Plan changes requested', }, }, - }) + }), ); } diff --git a/apps/hook/tsconfig.json b/apps/hook/tsconfig.json index 93ef3e2..3628fab 100644 --- a/apps/hook/tsconfig.json +++ b/apps/hook/tsconfig.json @@ -4,15 +4,9 @@ "experimentalDecorators": true, "useDefineForClassFields": false, "module": "ESNext", - "lib": [ - "ES2022", - "DOM", - "DOM.Iterable" - ], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "skipLibCheck": true, - "types": [ - "node" - ], + "types": ["node"], "moduleResolution": "bundler", "isolatedModules": true, "moduleDetection": "force", @@ -27,4 +21,4 @@ "allowImportingTsExtensions": true, "noEmit": true } -} \ No newline at end of file +} diff --git a/apps/hook/vite.config.ts b/apps/hook/vite.config.ts index 5577f88..d4445eb 100644 --- a/apps/hook/vite.config.ts +++ b/apps/hook/vite.config.ts @@ -1,8 +1,8 @@ -import path from 'path'; -import { defineConfig } from 'vite'; +import path from 'node:path'; +import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; import { viteSingleFile } from 'vite-plugin-singlefile'; -import tailwindcss from '@tailwindcss/vite'; import pkg from '../../package.json'; import { devMockApi } from './dev-mock-api'; @@ -21,7 +21,7 @@ export default defineConfig({ '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), '@plannotator/editor/styles': path.resolve(__dirname, '../../packages/editor/index.css'), '@plannotator/editor': path.resolve(__dirname, '../../packages/editor/App.tsx'), - } + }, }, build: { target: 'esnext', diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index e4c4bf4..b98820b 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -12,28 +12,21 @@ * @packageDocumentation */ -import { type Plugin, tool } from "@opencode-ai/plugin"; -import { - startPlannotatorServer, - handleServerReady, -} from "@plannotator/server"; -import { - startReviewServer, - handleReviewServerReady, -} from "@plannotator/server/review"; -import { - startAnnotateServer, - handleAnnotateServerReady, -} from "@plannotator/server/annotate"; -import { getGitContext, runGitDiff } from "@plannotator/server/git"; -import { writeRemoteShareLink } from "@plannotator/server/share-url"; - -// @ts-ignore - Bun import attribute for text -import indexHtml from "./plannotator.html" with { type: "text" }; +import { type Plugin, tool } from '@opencode-ai/plugin'; +import { handleServerReady, startPlannotatorServer } from '@plannotator/server'; +import { handleAnnotateServerReady, startAnnotateServer } from '@plannotator/server/annotate'; +import { getGitContext, runGitDiff } from '@plannotator/server/git'; +import { handleReviewServerReady, startReviewServer } from '@plannotator/server/review'; +import { writeRemoteShareLink } from '@plannotator/server/share-url'; + +// @ts-expect-error - Bun import attribute for text +import indexHtml from './plannotator.html' with { type: 'text' }; + const htmlContent = indexHtml as unknown as string; -// @ts-ignore - Bun import attribute for text -import reviewHtml from "./review-editor.html" with { type: "text" }; +// @ts-expect-error - Bun import attribute for text +import reviewHtml from './review-editor.html' with { type: 'text' }; + const reviewHtmlContent = reviewHtml as unknown as string; export const PlannotatorPlugin: Plugin = async (ctx) => { @@ -43,16 +36,16 @@ export const PlannotatorPlugin: Plugin = async (ctx) => { try { const response = await ctx.client.config.get({ query: { directory: ctx.directory } }); // Config is wrapped in response.data - // @ts-ignore - share config may exist + // @ts-expect-error - share config may exist const share = response?.data?.share; if (share !== undefined) { - return share !== "disabled"; + return share !== 'disabled'; } } catch { // Config read failed, fall through to env var } // Fall back to env var - return process.env.PLANNOTATOR_SHARE !== "disabled"; + return process.env.PLANNOTATOR_SHARE !== 'disabled'; } // Custom share portal URL for self-hosting @@ -64,26 +57,29 @@ export const PlannotatorPlugin: Plugin = async (ctx) => { // Register submit_plan as primary-only tool (hidden from sub-agents) config: async (opencodeConfig) => { const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? []; - if (!existingPrimaryTools.includes("submit_plan")) { + if (!existingPrimaryTools.includes('submit_plan')) { opencodeConfig.experimental = { ...opencodeConfig.experimental, - primary_tools: [...existingPrimaryTools, "submit_plan"], + primary_tools: [...existingPrimaryTools, 'submit_plan'], }; } }, // Inject planning instructions into system prompt - "experimental.chat.system.transform": async (input, output) => { + 'experimental.chat.system.transform': async (input, output) => { // Skip for title generation requests - const existingSystem = output.system.join("\n").toLowerCase(); - if (existingSystem.includes("title generator") || existingSystem.includes("generate a title")) { + const existingSystem = output.system.join('\n').toLowerCase(); + if ( + existingSystem.includes('title generator') || + existingSystem.includes('generate a title') + ) { return; } try { // Fetch session messages to determine current agent const messagesResponse = await ctx.client.session.messages({ - path: { id: input.sessionID } + path: { id: input.sessionID }, }); const messages = messagesResponse.data; @@ -92,8 +88,8 @@ export const PlannotatorPlugin: Plugin = async (ctx) => { if (messages) { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; - if (msg.info.role === "user") { - // @ts-ignore - UserMessage has agent field + if (msg.info.role === 'user') { + // @ts-expect-error - UserMessage has agent field lastUserAgent = msg.info.agent; break; } @@ -104,19 +100,18 @@ export const PlannotatorPlugin: Plugin = async (ctx) => { if (!lastUserAgent) return; // Hardcoded exclusion: build agent - if (lastUserAgent === "build") return; + if (lastUserAgent === 'build') return; // Dynamic exclusion: check agent mode via API const agentsResponse = await ctx.client.app.agents({ - query: { directory: ctx.directory } + query: { directory: ctx.directory }, }); const agents = agentsResponse.data; const agent = agents?.find((a: { name: string }) => a.name === lastUserAgent); // Skip if agent is a sub-agent - // @ts-ignore - Agent has mode field - if (agent?.mode === "subagent") return; - + // @ts-expect-error - Agent has mode field + if (agent?.mode === 'subagent') return; } catch { // Skip injection on any error (safer) return; @@ -143,35 +138,35 @@ Do NOT proceed with implementation until your plan is approved. event: async ({ event }) => { // Check for command execution event const isCommandEvent = - event.type === "command.executed" || - event.type === "tui.command.execute"; + event.type === 'command.executed' || event.type === 'tui.command.execute'; - // @ts-ignore - Event structure: event.properties.name for command.executed + // @ts-expect-error - Event structure: event.properties.name for command.executed const commandName = event.properties?.name || event.command || event.payload?.name; - const isReviewCommand = commandName === "plannotator-review"; + const isReviewCommand = commandName === 'plannotator-review'; if (isCommandEvent && isReviewCommand) { ctx.client.app.log({ - level: "info", - message: "Opening code review UI...", + level: 'info', + message: 'Opening code review UI...', }); // Get git context (branches, available diff options) const gitContext = await getGitContext(); // Run git diff HEAD (uncommitted changes - default) - const { patch: rawPatch, label: gitRef, error: diffError } = await runGitDiff( - "uncommitted", - gitContext.defaultBranch - ); + const { + patch: rawPatch, + label: gitRef, + error: diffError, + } = await runGitDiff('uncommitted', gitContext.defaultBranch); // Start server even if empty - user can switch diff types const server = await startReviewServer({ rawPatch, gitRef, error: diffError, - origin: "opencode", - diffType: "uncommitted", + origin: 'opencode', + diffType: 'uncommitted', gitContext, sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), @@ -186,7 +181,7 @@ Do NOT proceed with implementation until your plan is approved. // Send feedback back to the session if provided if (result.feedback) { - // @ts-ignore - Event properties contain sessionID for command.executed events + // @ts-expect-error - Event properties contain sessionID for command.executed events const sessionId = event.properties?.sessionID; // Only try to send feedback if we have a valid session ID @@ -203,7 +198,7 @@ Do NOT proceed with implementation until your plan is approved. ...(shouldSwitchAgent && { agent: targetAgent }), parts: [ { - type: "text", + type: 'text', text: `# Code Review Feedback\n\n${result.feedback}\n\nPlease address this feedback.`, }, ], @@ -217,34 +212,34 @@ Do NOT proceed with implementation until your plan is approved. } // Handle /plannotator-annotate command - const isAnnotateCommand = commandName === "plannotator-annotate"; + const isAnnotateCommand = commandName === 'plannotator-annotate'; if (isCommandEvent && isAnnotateCommand) { - // @ts-ignore - Event properties contain arguments - const filePath = event.properties?.arguments || event.arguments || ""; + // @ts-expect-error - Event properties contain arguments + const filePath = event.properties?.arguments || event.arguments || ''; if (!filePath) { ctx.client.app.log({ - level: "error", - message: "Usage: /plannotator-annotate ", + level: 'error', + message: 'Usage: /plannotator-annotate ', }); return; } ctx.client.app.log({ - level: "info", + level: 'info', message: `Opening annotation UI for ${filePath}...`, }); // Resolve to absolute path - const path = await import("path"); + const path = await import('node:path'); const absolutePath = path.resolve(filePath); // Read the markdown file const file = Bun.file(absolutePath); if (!(await file.exists())) { ctx.client.app.log({ - level: "error", + level: 'error', message: `File not found: ${absolutePath}`, }); return; @@ -255,7 +250,7 @@ Do NOT proceed with implementation until your plan is approved. const server = await startAnnotateServer({ markdown, filePath: absolutePath, - origin: "opencode", + origin: 'opencode', sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), htmlContent: htmlContent, @@ -268,7 +263,7 @@ Do NOT proceed with implementation until your plan is approved. // Send feedback back to the session if provided if (result.feedback) { - // @ts-ignore - Event properties contain sessionID for command.executed events + // @ts-expect-error - Event properties contain sessionID for command.executed events const sessionId = event.properties?.sessionID; if (sessionId) { @@ -278,7 +273,7 @@ Do NOT proceed with implementation until your plan is approved. body: { parts: [ { - type: "text", + type: 'text', text: `# Markdown Annotations\n\nFile: ${absolutePath}\n\n${result.feedback}\n\nPlease address the annotation feedback above.`, }, ], @@ -295,28 +290,33 @@ Do NOT proceed with implementation until your plan is approved. tool: { submit_plan: tool({ description: - "Submit your completed plan for interactive user review. The user can annotate, approve, or request changes. Call this when you have finished creating your implementation plan.", + 'Submit your completed plan for interactive user review. The user can annotate, approve, or request changes. Call this when you have finished creating your implementation plan.', args: { plan: tool.schema .string() - .describe("The complete implementation plan in markdown format"), + .describe('The complete implementation plan in markdown format'), summary: tool.schema .string() - .describe("A brief 1-2 sentence summary of what the plan accomplishes"), + .describe('A brief 1-2 sentence summary of what the plan accomplishes'), }, async execute(args, context) { const server = await startPlannotatorServer({ plan: args.plan, - origin: "opencode", + origin: 'opencode', sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), htmlContent, opencodeClient: ctx.client, onReady: async (url, isRemote, port) => { handleServerReady(url, isRemote, port); - if (isRemote && await getSharingEnabled()) { - await writeRemoteShareLink(args.plan, getShareBaseUrl(), "review the plan", "plan only").catch(() => {}); + if (isRemote && (await getSharingEnabled())) { + await writeRemoteShareLink( + args.plan, + getShareBaseUrl(), + 'review the plan', + 'plan only', + ).catch(() => {}); } }, }); @@ -324,11 +324,19 @@ Do NOT proceed with implementation until your plan is approved. const PLANNOTATOR_TIMEOUT_MS = 10 * 60 * 1000; // 10min timeout let timeoutId: ReturnType; const result = await Promise.race([ - server.waitForDecision().then((r) => { clearTimeout(timeoutId); return r; }), + server.waitForDecision().then((r) => { + clearTimeout(timeoutId); + return r; + }), new Promise<{ approved: boolean; feedback?: string }>((resolve) => { timeoutId = setTimeout( - () => resolve({ approved: false, feedback: "[Plannotator] No response within 10 minutes. Port released automatically. Please call submit_plan again." }), - PLANNOTATOR_TIMEOUT_MS + () => + resolve({ + approved: false, + feedback: + '[Plannotator] No response within 10 minutes. Port released automatically. Please call submit_plan again.', + }), + PLANNOTATOR_TIMEOUT_MS, ); }), ]); @@ -344,7 +352,7 @@ Do NOT proceed with implementation until your plan is approved. // Switch TUI display to target agent try { await ctx.client.tui.executeCommand({ - body: { command: "agent_cycle" }, + body: { command: 'agent_cycle' }, }); } catch { // Silently fail @@ -360,7 +368,7 @@ Do NOT proceed with implementation until your plan is approved. body: { agent: targetAgent, noReply: true, - parts: [{ type: "text", text: "Proceed with implementation" }], + parts: [{ type: 'text', text: 'Proceed with implementation' }], }, }); } catch { @@ -373,7 +381,7 @@ Do NOT proceed with implementation until your plan is approved. return `Plan approved with notes! Plan Summary: ${args.summary} -${result.savedPath ? `Saved to: ${result.savedPath}` : ""} +${result.savedPath ? `Saved to: ${result.savedPath}` : ''} ## Implementation Notes @@ -387,10 +395,10 @@ Proceed with implementation, incorporating these notes where applicable.`; return `Plan approved! Plan Summary: ${args.summary} -${result.savedPath ? `Saved to: ${result.savedPath}` : ""}`; +${result.savedPath ? `Saved to: ${result.savedPath}` : ''}`; } else { return `Plan needs revision. -${result.savedPath ? `\nSaved to: ${result.savedPath}` : ""} +${result.savedPath ? `\nSaved to: ${result.savedPath}` : ''} The user has requested changes to your plan. Please review their feedback below and revise your plan accordingly. diff --git a/apps/paste-service/core/cors.ts b/apps/paste-service/core/cors.ts index 548fc8e..027ae20 100644 --- a/apps/paste-service/core/cors.ts +++ b/apps/paste-service/core/cors.ts @@ -1,25 +1,25 @@ const BASE_CORS_HEADERS = { - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - "Access-Control-Max-Age": "86400", + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', }; export function getAllowedOrigins(envValue?: string): string[] { if (envValue) { - return envValue.split(",").map((o) => o.trim()); + return envValue.split(',').map((o) => o.trim()); } - return ["https://share.plannotator.ai", "http://localhost:3001"]; + return ['https://share.plannotator.ai', 'http://localhost:3001']; } export function corsHeaders( requestOrigin: string, - allowedOrigins: string[] + allowedOrigins: string[], ): Record { const isLocalhost = /^https?:\/\/localhost(:\d+)?$/.test(requestOrigin); - if (isLocalhost || allowedOrigins.includes(requestOrigin) || allowedOrigins.includes("*")) { + if (isLocalhost || allowedOrigins.includes(requestOrigin) || allowedOrigins.includes('*')) { return { ...BASE_CORS_HEADERS, - "Access-Control-Allow-Origin": requestOrigin, + 'Access-Control-Allow-Origin': requestOrigin, }; } return {}; diff --git a/apps/paste-service/core/handler.ts b/apps/paste-service/core/handler.ts index 716316b..945b410 100644 --- a/apps/paste-service/core/handler.ts +++ b/apps/paste-service/core/handler.ts @@ -1,5 +1,4 @@ -import type { PasteStore } from "./storage"; -import { corsHeaders } from "./cors"; +import type { PasteStore } from './storage'; export interface PasteOptions { maxSize: number; @@ -18,8 +17,7 @@ const ID_PATTERN = /^\/api\/paste\/([A-Za-z0-9]{6,16})$/; * Uses Web Crypto with rejection sampling to avoid modulo bias. */ function generateId(): string { - const chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const limit = 256 - (256 % chars.length); // 248 — largest multiple of 62 that fits in a byte const id: string[] = []; while (id.length < 8) { @@ -32,24 +30,24 @@ function generateId(): string { } } } - return id.join(""); + return id.join(''); } export async function createPaste( data: string, store: PasteStore, - options: Partial = {} + options: Partial = {}, ): Promise<{ id: string }> { const opts = { ...DEFAULT_OPTIONS, ...options }; - if (!data || typeof data !== "string") { + if (!data || typeof data !== 'string') { throw new PasteError('Missing or invalid "data" field', 400); } if (data.length > opts.maxSize) { throw new PasteError( `Payload too large (max ${Math.round(opts.maxSize / 1024)} KB compressed)`, - 413 + 413, ); } @@ -58,17 +56,14 @@ export async function createPaste( return { id }; } -export async function getPaste( - id: string, - store: PasteStore -): Promise { +export async function getPaste(id: string, store: PasteStore): Promise { return store.get(id); } export class PasteError extends Error { constructor( message: string, - public status: number + public status: number, ) { super(message); } @@ -82,63 +77,51 @@ export async function handleRequest( request: Request, store: PasteStore, cors: Record, - options?: Partial + options?: Partial, ): Promise { const url = new URL(request.url); - if (request.method === "OPTIONS") { + if (request.method === 'OPTIONS') { return new Response(null, { status: 204, headers: cors }); } - if (url.pathname === "/api/paste" && request.method === "POST") { + if (url.pathname === '/api/paste' && request.method === 'POST') { let body: { data?: unknown }; try { body = (await request.json()) as { data?: unknown }; } catch { - return Response.json( - { error: "Invalid JSON body" }, - { status: 400, headers: cors } - ); + return Response.json({ error: 'Invalid JSON body' }, { status: 400, headers: cors }); } try { const result = await createPaste(body.data as string, store, options); return Response.json(result, { status: 201, headers: cors }); } catch (e) { if (e instanceof PasteError) { - return Response.json( - { error: e.message }, - { status: e.status, headers: cors } - ); + return Response.json({ error: e.message }, { status: e.status, headers: cors }); } - return Response.json( - { error: "Failed to store paste" }, - { status: 500, headers: cors } - ); + return Response.json({ error: 'Failed to store paste' }, { status: 500, headers: cors }); } } const match = url.pathname.match(ID_PATTERN); - if (match && request.method === "GET") { + if (match && request.method === 'GET') { const data = await getPaste(match[1], store); if (!data) { - return Response.json( - { error: "Paste not found or expired" }, - { status: 404, headers: cors } - ); + return Response.json({ error: 'Paste not found or expired' }, { status: 404, headers: cors }); } return Response.json( { data }, { headers: { ...cors, - "Cache-Control": "private, no-store", + 'Cache-Control': 'private, no-store', }, - } + }, ); } return Response.json( - { error: "Not found. Valid paths: POST /api/paste, GET /api/paste/:id" }, - { status: 404, headers: cors } + { error: 'Not found. Valid paths: POST /api/paste, GET /api/paste/:id' }, + { status: 404, headers: cors }, ); } diff --git a/apps/paste-service/stores/fs.ts b/apps/paste-service/stores/fs.ts index 9e4a72a..863c4c3 100644 --- a/apps/paste-service/stores/fs.ts +++ b/apps/paste-service/stores/fs.ts @@ -1,6 +1,6 @@ -import { mkdirSync, readdirSync, readFileSync, unlinkSync } from "fs"; -import { join, resolve } from "path"; -import type { PasteStore } from "../core/storage"; +import { mkdirSync, readdirSync, readFileSync, unlinkSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import type { PasteStore } from '../core/storage'; interface PasteFile { data: string; @@ -19,7 +19,7 @@ export class FsPasteStore implements PasteStore { private safePath(id: string): string { const filePath = resolve(join(this.dataDir, `${id}.json`)); if (!filePath.startsWith(this.resolvedDir)) { - throw new Error("Invalid paste ID"); + throw new Error('Invalid paste ID'); } return filePath; } @@ -49,12 +49,12 @@ export class FsPasteStore implements PasteStore { /** Delete expired pastes on startup */ private sweep(): void { try { - const files = readdirSync(this.dataDir).filter((f) => f.endsWith(".json")); + const files = readdirSync(this.dataDir).filter((f) => f.endsWith('.json')); const now = Date.now(); for (const file of files) { const path = join(this.dataDir, file); try { - const raw = readFileSync(path, "utf-8"); + const raw = readFileSync(path, 'utf-8'); const entry: PasteFile = JSON.parse(raw); if (now > entry.expiresAt) { unlinkSync(path); diff --git a/apps/paste-service/stores/kv.ts b/apps/paste-service/stores/kv.ts index 74ce189..16ee101 100644 --- a/apps/paste-service/stores/kv.ts +++ b/apps/paste-service/stores/kv.ts @@ -1,4 +1,4 @@ -import type { PasteStore } from "../core/storage"; +import type { PasteStore } from '../core/storage'; /** * Cloudflare KV-backed paste store. diff --git a/apps/paste-service/stores/s3.ts b/apps/paste-service/stores/s3.ts deleted file mode 100644 index faee4aa..0000000 --- a/apps/paste-service/stores/s3.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { PasteStore } from "../core/storage"; - -/** - * S3-compatible paste store (future implementation). - * - * TTL handled via S3 lifecycle rules configured on the bucket. - * Implement when needed for AWS Lambda or other cloud deployments. - */ -export class S3PasteStore implements PasteStore { - async put(_id: string, _data: string, _ttlSeconds: number): Promise { - throw new Error("S3PasteStore not yet implemented"); - } - - async get(_id: string): Promise { - throw new Error("S3PasteStore not yet implemented"); - } -} diff --git a/apps/paste-service/targets/bun.ts b/apps/paste-service/targets/bun.ts index a670e92..118e3f2 100644 --- a/apps/paste-service/targets/bun.ts +++ b/apps/paste-service/targets/bun.ts @@ -1,15 +1,14 @@ -import { homedir } from "os"; -import { join } from "path"; -import { handleRequest } from "../core/handler"; -import { corsHeaders, getAllowedOrigins } from "../core/cors"; -import { FsPasteStore } from "../stores/fs"; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { corsHeaders, getAllowedOrigins } from '../core/cors'; +import { handleRequest } from '../core/handler'; +import { FsPasteStore } from '../stores/fs'; -const port = parseInt(process.env.PASTE_PORT || "19433", 10); -const dataDir = - process.env.PASTE_DATA_DIR || join(homedir(), ".plannotator", "pastes"); -const ttlDays = parseInt(process.env.PASTE_TTL_DAYS || "7", 10); +const port = parseInt(process.env.PASTE_PORT || '19433', 10); +const dataDir = process.env.PASTE_DATA_DIR || join(homedir(), '.plannotator', 'pastes'); +const ttlDays = parseInt(process.env.PASTE_TTL_DAYS || '7', 10); const ttlSeconds = ttlDays * 24 * 60 * 60; -const maxSize = parseInt(process.env.PASTE_MAX_SIZE || "524288", 10); +const maxSize = parseInt(process.env.PASTE_MAX_SIZE || '524288', 10); const allowedOrigins = getAllowedOrigins(process.env.PASTE_ALLOWED_ORIGINS); const store = new FsPasteStore(dataDir); @@ -17,7 +16,7 @@ const store = new FsPasteStore(dataDir); Bun.serve({ port, async fetch(request) { - const origin = request.headers.get("Origin") ?? ""; + const origin = request.headers.get('Origin') ?? ''; const cors = corsHeaders(origin, allowedOrigins); return handleRequest(request, store, cors, { maxSize, ttlSeconds }); }, diff --git a/apps/paste-service/targets/cloudflare.ts b/apps/paste-service/targets/cloudflare.ts index 4b31f17..d3eb741 100644 --- a/apps/paste-service/targets/cloudflare.ts +++ b/apps/paste-service/targets/cloudflare.ts @@ -1,6 +1,6 @@ -import { handleRequest } from "../core/handler"; -import { corsHeaders, getAllowedOrigins } from "../core/cors"; -import { KvPasteStore } from "../stores/kv"; +import { corsHeaders, getAllowedOrigins } from '../core/cors'; +import { handleRequest } from '../core/handler'; +import { KvPasteStore } from '../stores/kv'; interface Env { PASTE_KV: KVNamespace; @@ -9,7 +9,7 @@ interface Env { export default { async fetch(request: Request, env: Env): Promise { - const origin = request.headers.get("Origin") ?? ""; + const origin = request.headers.get('Origin') ?? ''; const allowed = getAllowedOrigins(env.ALLOWED_ORIGINS); const cors = corsHeaders(origin, allowed); const store = new KvPasteStore(env.PASTE_KV); diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index c14892c..0d68aba 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -18,74 +18,74 @@ * - /plannotator-annotate command for markdown annotation */ -import { readFileSync, existsSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai"; -import { Type } from "@mariozechner/pi-ai"; -import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; -import { Key } from "@mariozechner/pi-tui"; -import { isSafeCommand, markCompletedSteps, parseChecklist, type ChecklistItem } from "./utils.js"; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { AgentMessage } from '@mariozechner/pi-agent-core'; +import type { AssistantMessage, TextContent } from '@mariozechner/pi-ai'; +import { Type } from '@mariozechner/pi-ai'; +import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent'; +import { Key } from '@mariozechner/pi-tui'; import { - startPlanReviewServer, - startReviewServer, - startAnnotateServer, getGitContext, - runGitDiff, openBrowser, -} from "./server.js"; + runGitDiff, + startAnnotateServer, + startPlanReviewServer, + startReviewServer, +} from './server.js'; +import { type ChecklistItem, isSafeCommand, markCompletedSteps, parseChecklist } from './utils.js'; // Load HTML at runtime (jiti doesn't support import attributes) const __dirname = dirname(fileURLToPath(import.meta.url)); -let planHtmlContent = ""; -let reviewHtmlContent = ""; +let planHtmlContent = ''; +let reviewHtmlContent = ''; try { - planHtmlContent = readFileSync(resolve(__dirname, "plannotator.html"), "utf-8"); + planHtmlContent = readFileSync(resolve(__dirname, 'plannotator.html'), 'utf-8'); } catch { // HTML not built yet — browser features will be unavailable } try { - reviewHtmlContent = readFileSync(resolve(__dirname, "review-editor.html"), "utf-8"); + reviewHtmlContent = readFileSync(resolve(__dirname, 'review-editor.html'), 'utf-8'); } catch { // HTML not built yet — review feature will be unavailable } // Tool sets by phase -const PLANNING_TOOLS = ["read", "bash", "grep", "find", "ls", "write", "edit", "exit_plan_mode"]; -const EXECUTION_TOOLS = ["read", "bash", "edit", "write"]; -const NORMAL_TOOLS = ["read", "bash", "edit", "write"]; +const PLANNING_TOOLS = ['read', 'bash', 'grep', 'find', 'ls', 'write', 'edit', 'exit_plan_mode']; +const EXECUTION_TOOLS = ['read', 'bash', 'edit', 'write']; +const NORMAL_TOOLS = ['read', 'bash', 'edit', 'write']; -type Phase = "idle" | "planning" | "executing"; +type Phase = 'idle' | 'planning' | 'executing'; function isAssistantMessage(m: AgentMessage): m is AssistantMessage { - return m.role === "assistant" && Array.isArray(m.content); + return m.role === 'assistant' && Array.isArray(m.content); } function getTextContent(message: AssistantMessage): string { return message.content - .filter((block): block is TextContent => block.type === "text") + .filter((block): block is TextContent => block.type === 'text') .map((block) => block.text) - .join("\n"); + .join('\n'); } export default function plannotator(pi: ExtensionAPI): void { - let phase: Phase = "idle"; - let planFilePath = "PLAN.md"; + let phase: Phase = 'idle'; + let planFilePath = 'PLAN.md'; let checklistItems: ChecklistItem[] = []; // ── Flags ──────────────────────────────────────────────────────────── - pi.registerFlag("plan", { - description: "Start in plan mode (read-only exploration)", - type: "boolean", + pi.registerFlag('plan', { + description: 'Start in plan mode (read-only exploration)', + type: 'boolean', default: false, }); - pi.registerFlag("plan-file", { - description: "Plan file path (default: PLAN.md)", - type: "string", - default: "PLAN.md", + pi.registerFlag('plan-file', { + description: 'Plan file path (default: PLAN.md)', + type: 'string', + default: 'PLAN.md', }); // ── Helpers ────────────────────────────────────────────────────────── @@ -95,39 +95,42 @@ export default function plannotator(pi: ExtensionAPI): void { } function updateStatus(ctx: ExtensionContext): void { - if (phase === "executing" && checklistItems.length > 0) { + if (phase === 'executing' && checklistItems.length > 0) { const completed = checklistItems.filter((t) => t.completed).length; - ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("accent", `📋 ${completed}/${checklistItems.length}`)); - } else if (phase === "planning") { - ctx.ui.setStatus("plannotator", ctx.ui.theme.fg("warning", "⏸ plan")); + ctx.ui.setStatus( + 'plannotator', + ctx.ui.theme.fg('accent', `📋 ${completed}/${checklistItems.length}`), + ); + } else if (phase === 'planning') { + ctx.ui.setStatus('plannotator', ctx.ui.theme.fg('warning', '⏸ plan')); } else { - ctx.ui.setStatus("plannotator", undefined); + ctx.ui.setStatus('plannotator', undefined); } } function updateWidget(ctx: ExtensionContext): void { - if (phase === "executing" && checklistItems.length > 0) { + if (phase === 'executing' && checklistItems.length > 0) { const lines = checklistItems.map((item) => { if (item.completed) { return ( - ctx.ui.theme.fg("success", "☑ ") + - ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text)) + ctx.ui.theme.fg('success', '☑ ') + + ctx.ui.theme.fg('muted', ctx.ui.theme.strikethrough(item.text)) ); } - return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`; + return `${ctx.ui.theme.fg('muted', '☐ ')}${item.text}`; }); - ctx.ui.setWidget("plannotator-progress", lines); + ctx.ui.setWidget('plannotator-progress', lines); } else { - ctx.ui.setWidget("plannotator-progress", undefined); + ctx.ui.setWidget('plannotator-progress', undefined); } } function persistState(): void { - pi.appendEntry("plannotator", { phase, planFilePath }); + pi.appendEntry('plannotator', { phase, planFilePath }); } function enterPlanning(ctx: ExtensionContext): void { - phase = "planning"; + phase = 'planning'; checklistItems = []; pi.setActiveTools(PLANNING_TOOLS); updateStatus(ctx); @@ -137,17 +140,17 @@ export default function plannotator(pi: ExtensionAPI): void { } function exitToIdle(ctx: ExtensionContext): void { - phase = "idle"; + phase = 'idle'; checklistItems = []; pi.setActiveTools(NORMAL_TOOLS); updateStatus(ctx); updateWidget(ctx); persistState(); - ctx.ui.notify("Plannotator: disabled. Full access restored."); + ctx.ui.notify('Plannotator: disabled. Full access restored.'); } function togglePlanMode(ctx: ExtensionContext): void { - if (phase === "idle") { + if (phase === 'idle') { enterPlanning(ctx); } else { exitToIdle(ctx); @@ -156,41 +159,44 @@ export default function plannotator(pi: ExtensionAPI): void { // ── Commands & Shortcuts ───────────────────────────────────────────── - pi.registerCommand("plannotator", { - description: "Toggle plannotator (file-based plan mode)", + pi.registerCommand('plannotator', { + description: 'Toggle plannotator (file-based plan mode)', handler: async (_args, ctx) => togglePlanMode(ctx), }); - pi.registerCommand("plannotator-status", { - description: "Show plannotator status", + pi.registerCommand('plannotator-status', { + description: 'Show plannotator status', handler: async (_args, ctx) => { const parts = [`Phase: ${phase}`, `Plan file: ${planFilePath}`]; if (checklistItems.length > 0) { const done = checklistItems.filter((t) => t.completed).length; parts.push(`Progress: ${done}/${checklistItems.length}`); } - ctx.ui.notify(parts.join("\n"), "info"); + ctx.ui.notify(parts.join('\n'), 'info'); }, }); - pi.registerCommand("plannotator-review", { - description: "Open interactive code review for current changes", + pi.registerCommand('plannotator-review', { + description: 'Open interactive code review for current changes', handler: async (_args, ctx) => { if (!reviewHtmlContent) { - ctx.ui.notify("Review UI not available. Run 'bun run build' in the pi-extension directory.", "error"); + ctx.ui.notify( + "Review UI not available. Run 'bun run build' in the pi-extension directory.", + 'error', + ); return; } - ctx.ui.notify("Opening code review UI...", "info"); + ctx.ui.notify('Opening code review UI...', 'info'); const gitCtx = getGitContext(); - const { patch: rawPatch, label: gitRef } = runGitDiff("uncommitted", gitCtx.defaultBranch); + const { patch: rawPatch, label: gitRef } = runGitDiff('uncommitted', gitCtx.defaultBranch); const server = startReviewServer({ rawPatch, gitRef, - origin: "pi", - diffType: "uncommitted", + origin: 'pi', + diffType: 'uncommitted', gitContext: gitCtx, htmlContent: reviewHtmlContent, }); @@ -202,39 +208,44 @@ export default function plannotator(pi: ExtensionAPI): void { server.stop(); if (result.feedback) { - pi.sendUserMessage(`# Code Review Feedback\n\n${result.feedback}\n\nPlease address this feedback.`); + pi.sendUserMessage( + `# Code Review Feedback\n\n${result.feedback}\n\nPlease address this feedback.`, + ); } else { - ctx.ui.notify("Code review closed (no feedback).", "info"); + ctx.ui.notify('Code review closed (no feedback).', 'info'); } }, }); - pi.registerCommand("plannotator-annotate", { - description: "Open markdown file in annotation UI", + pi.registerCommand('plannotator-annotate', { + description: 'Open markdown file in annotation UI', handler: async (args, ctx) => { const filePath = args?.trim(); if (!filePath) { - ctx.ui.notify("Usage: /plannotator-annotate ", "error"); + ctx.ui.notify('Usage: /plannotator-annotate ', 'error'); return; } if (!planHtmlContent) { - ctx.ui.notify("Annotation UI not available. Run 'bun run build' in the pi-extension directory.", "error"); + ctx.ui.notify( + "Annotation UI not available. Run 'bun run build' in the pi-extension directory.", + 'error', + ); return; } const absolutePath = resolve(ctx.cwd, filePath); if (!existsSync(absolutePath)) { - ctx.ui.notify(`File not found: ${absolutePath}`, "error"); + ctx.ui.notify(`File not found: ${absolutePath}`, 'error'); return; } - ctx.ui.notify(`Opening annotation UI for ${filePath}...`, "info"); + ctx.ui.notify(`Opening annotation UI for ${filePath}...`, 'info'); - const markdown = readFileSync(absolutePath, "utf-8"); + const markdown = readFileSync(absolutePath, 'utf-8'); const server = startAnnotateServer({ markdown, filePath: absolutePath, - origin: "pi", + origin: 'pi', htmlContent: planHtmlContent, }); @@ -249,37 +260,42 @@ export default function plannotator(pi: ExtensionAPI): void { `# Markdown Annotations\n\nFile: ${absolutePath}\n\n${result.feedback}\n\nPlease address the annotation feedback above.`, ); } else { - ctx.ui.notify("Annotation closed (no feedback).", "info"); + ctx.ui.notify('Annotation closed (no feedback).', 'info'); } }, }); - pi.registerShortcut(Key.ctrlAlt("p"), { - description: "Toggle plannotator", + pi.registerShortcut(Key.ctrlAlt('p'), { + description: 'Toggle plannotator', handler: async (ctx) => togglePlanMode(ctx), }); // ── exit_plan_mode Tool ────────────────────────────────────────────── pi.registerTool({ - name: "exit_plan_mode", - label: "Exit Plan Mode", + name: 'exit_plan_mode', + label: 'Exit Plan Mode', description: - "Submit your plan for user review. " + - "Call this after drafting or revising your plan in PLAN.md. " + - "The user will review the plan in a visual browser UI and can approve, deny with feedback, or annotate it. " + - "If denied, use the edit tool to make targeted revisions (not write), then call this again.", + 'Submit your plan for user review. ' + + 'Call this after drafting or revising your plan in PLAN.md. ' + + 'The user will review the plan in a visual browser UI and can approve, deny with feedback, or annotate it. ' + + 'If denied, use the edit tool to make targeted revisions (not write), then call this again.', parameters: Type.Object({ summary: Type.Optional( Type.String({ description: "Brief summary of the plan for the user's review" }), ), }), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { // Guard: must be in planning phase - if (phase !== "planning") { + if (phase !== 'planning') { return { - content: [{ type: "text", text: "Error: Not in plan mode. Use /plannotator to enter planning mode first." }], + content: [ + { + type: 'text', + text: 'Error: Not in plan mode. Use /plannotator to enter planning mode first.', + }, + ], details: { approved: false }, }; } @@ -288,12 +304,12 @@ export default function plannotator(pi: ExtensionAPI): void { const fullPath = resolvePlanPath(ctx.cwd); let planContent: string; try { - planContent = readFileSync(fullPath, "utf-8"); + planContent = readFileSync(fullPath, 'utf-8'); } catch { return { content: [ { - type: "text", + type: 'text', text: `Error: ${planFilePath} does not exist. Write your plan using the write tool first, then call exit_plan_mode again.`, }, ], @@ -305,7 +321,7 @@ export default function plannotator(pi: ExtensionAPI): void { return { content: [ { - type: "text", + type: 'text', text: `Error: ${planFilePath} is empty. Write your plan first, then call exit_plan_mode again.`, }, ], @@ -318,14 +334,14 @@ export default function plannotator(pi: ExtensionAPI): void { // Non-interactive or no HTML: auto-approve if (!ctx.hasUI || !planHtmlContent) { - phase = "executing"; + phase = 'executing'; pi.setActiveTools(EXECUTION_TOOLS); persistState(); return { content: [ { - type: "text", - text: "Plan auto-approved (non-interactive mode). Execute the plan now.", + type: 'text', + text: 'Plan auto-approved (non-interactive mode). Execute the plan now.', }, ], details: { approved: true }, @@ -336,7 +352,7 @@ export default function plannotator(pi: ExtensionAPI): void { const server = startPlanReviewServer({ plan: planContent, htmlContent: planHtmlContent, - origin: "pi", + origin: 'pi', }); openBrowser(server.url); @@ -347,23 +363,24 @@ export default function plannotator(pi: ExtensionAPI): void { server.stop(); if (result.approved) { - phase = "executing"; + phase = 'executing'; pi.setActiveTools(EXECUTION_TOOLS); updateStatus(ctx); updateWidget(ctx); persistState(); - pi.appendEntry("plannotator-execute", { planFilePath }); + pi.appendEntry('plannotator-execute', { planFilePath }); - const doneMsg = checklistItems.length > 0 - ? `After completing each step, include [DONE:n] in your response where n is the step number.` - : ""; + const doneMsg = + checklistItems.length > 0 + ? `After completing each step, include [DONE:n] in your response where n is the step number.` + : ''; if (result.feedback) { return { content: [ { - type: "text", + type: 'text', text: `Plan approved with notes! You now have full tool access (read, bash, edit, write). Execute the plan in ${planFilePath}. ${doneMsg}\n\n## Implementation Notes\n\nThe user approved your plan but added the following notes to consider during implementation:\n\n${result.feedback}\n\nProceed with implementation, incorporating these notes where applicable.`, }, ], @@ -374,7 +391,7 @@ export default function plannotator(pi: ExtensionAPI): void { return { content: [ { - type: "text", + type: 'text', text: `Plan approved. You now have full tool access (read, bash, edit, write). Execute the plan in ${planFilePath}. ${doneMsg}`, }, ], @@ -383,11 +400,11 @@ export default function plannotator(pi: ExtensionAPI): void { } // Denied - const feedbackText = result.feedback || "Plan rejected. Please revise."; + const feedbackText = result.feedback || 'Plan rejected. Please revise.'; return { content: [ { - type: "text", + type: 'text', text: `Plan not approved.\n\nUser feedback: ${feedbackText}\n\nRevise the plan:\n1. Read ${planFilePath} to see the current plan.\n2. Use the edit tool to make targeted changes addressing the feedback above — do not rewrite the entire file.\n3. Call exit_plan_mode again when ready.`, }, ], @@ -399,10 +416,10 @@ export default function plannotator(pi: ExtensionAPI): void { // ── Event Handlers ─────────────────────────────────────────────────── // Gate writes and bash during planning - pi.on("tool_call", async (event, ctx) => { - if (phase !== "planning") return; + pi.on('tool_call', async (event, ctx) => { + if (phase !== 'planning') return; - if (event.toolName === "bash") { + if (event.toolName === 'bash') { const command = event.input.command as string; if (!isSafeCommand(command)) { return { @@ -412,7 +429,7 @@ export default function plannotator(pi: ExtensionAPI): void { } } - if (event.toolName === "write") { + if (event.toolName === 'write') { const targetPath = resolve(ctx.cwd, event.input.path as string); const allowedPath = resolvePlanPath(ctx.cwd); if (targetPath !== allowedPath) { @@ -423,7 +440,7 @@ export default function plannotator(pi: ExtensionAPI): void { } } - if (event.toolName === "edit") { + if (event.toolName === 'edit') { const targetPath = resolve(ctx.cwd, event.input.path as string); const allowedPath = resolvePlanPath(ctx.cwd); if (targetPath !== allowedPath) { @@ -436,11 +453,11 @@ export default function plannotator(pi: ExtensionAPI): void { }); // Inject phase-specific context - pi.on("before_agent_start", async (_event, ctx) => { - if (phase === "planning") { + pi.on('before_agent_start', async (_event, ctx) => { + if (phase === 'planning') { return { message: { - customType: "plannotator-context", + customType: 'plannotator-context', content: `[PLANNOTATOR - PLANNING PHASE] You are in plan mode. You MUST NOT make any changes to the codebase — no edits, no commits, no installs, no destructive commands. The ONLY file you may write to or edit is the plan file: ${planFilePath}. @@ -506,12 +523,12 @@ Do not end your turn without doing one of these two things.`, }; } - if (phase === "executing" && checklistItems.length > 0) { + if (phase === 'executing' && checklistItems.length > 0) { // Re-read from disk each turn to stay current const fullPath = resolvePlanPath(ctx.cwd); - let planContent = ""; + let planContent = ''; try { - planContent = readFileSync(fullPath, "utf-8"); + planContent = readFileSync(fullPath, 'utf-8'); checklistItems = parseChecklist(planContent); } catch { // File deleted during execution — degrade gracefully @@ -519,10 +536,10 @@ Do not end your turn without doing one of these two things.`, const remaining = checklistItems.filter((t) => !t.completed); if (remaining.length > 0) { - const todoList = remaining.map((t) => `- [ ] ${t.step}. ${t.text}`).join("\n"); + const todoList = remaining.map((t) => `- [ ] ${t.step}. ${t.text}`).join('\n'); return { message: { - customType: "plannotator-context", + customType: 'plannotator-context', content: `[PLANNOTATOR - EXECUTING PLAN] Full tool access is enabled. Execute the plan from ${planFilePath}. @@ -538,22 +555,22 @@ Execute each step in order. After completing a step, include [DONE:n] in your re }); // Filter stale context when idle - pi.on("context", async (event) => { - if (phase !== "idle") return; + pi.on('context', async (event) => { + if (phase !== 'idle') return; return { messages: event.messages.filter((m) => { const msg = m as AgentMessage & { customType?: string }; - if (msg.customType === "plannotator-context") return false; - if (msg.role !== "user") return true; + if (msg.customType === 'plannotator-context') return false; + if (msg.role !== 'user') return true; const content = msg.content; - if (typeof content === "string") { - return !content.includes("[PLANNOTATOR -"); + if (typeof content === 'string') { + return !content.includes('[PLANNOTATOR -'); } if (Array.isArray(content)) { return !content.some( - (c) => c.type === "text" && (c as TextContent).text?.includes("[PLANNOTATOR -"), + (c) => c.type === 'text' && (c as TextContent).text?.includes('[PLANNOTATOR -'), ); } return true; @@ -562,8 +579,8 @@ Execute each step in order. After completing a step, include [DONE:n] in your re }); // Track execution progress - pi.on("turn_end", async (event, ctx) => { - if (phase !== "executing" || checklistItems.length === 0) return; + pi.on('turn_end', async (event, ctx) => { + if (phase !== 'executing' || checklistItems.length === 0) return; if (!isAssistantMessage(event.message)) return; const text = getTextContent(event.message); @@ -575,20 +592,20 @@ Execute each step in order. After completing a step, include [DONE:n] in your re }); // Detect execution completion - pi.on("agent_end", async (_event, ctx) => { - if (phase !== "executing" || checklistItems.length === 0) return; + pi.on('agent_end', async (_event, ctx) => { + if (phase !== 'executing' || checklistItems.length === 0) return; if (checklistItems.every((t) => t.completed)) { - const completedList = checklistItems.map((t) => `- [x] ~~${t.text}~~`).join("\n"); + const completedList = checklistItems.map((t) => `- [x] ~~${t.text}~~`).join('\n'); pi.sendMessage( { - customType: "plannotator-complete", + customType: 'plannotator-complete', content: `**Plan Complete!** ✓\n\n${completedList}`, display: true, }, { triggerTurn: false }, ); - phase = "idle"; + phase = 'idle'; checklistItems = []; pi.setActiveTools(NORMAL_TOOLS); updateStatus(ctx); @@ -598,22 +615,25 @@ Execute each step in order. After completing a step, include [DONE:n] in your re }); // Restore state on session start/resume - pi.on("session_start", async (_event, ctx) => { + pi.on('session_start', async (_event, ctx) => { // Resolve plan file path from flag - const flagPlanFile = pi.getFlag("plan-file") as string; + const flagPlanFile = pi.getFlag('plan-file') as string; if (flagPlanFile) { planFilePath = flagPlanFile; } // Check --plan flag - if (pi.getFlag("plan") === true) { - phase = "planning"; + if (pi.getFlag('plan') === true) { + phase = 'planning'; } // Restore persisted state const entries = ctx.sessionManager.getEntries(); const stateEntry = entries - .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plannotator") + .filter( + (e: { type: string; customType?: string }) => + e.type === 'custom' && e.customType === 'plannotator', + ) .pop() as { data?: { phase: Phase; planFilePath?: string } } | undefined; if (stateEntry?.data) { @@ -622,17 +642,17 @@ Execute each step in order. After completing a step, include [DONE:n] in your re } // Rebuild execution state from disk + session messages - if (phase === "executing") { + if (phase === 'executing') { const fullPath = resolvePlanPath(ctx.cwd); if (existsSync(fullPath)) { - const content = readFileSync(fullPath, "utf-8"); + const content = readFileSync(fullPath, 'utf-8'); checklistItems = parseChecklist(content); // Find last execution marker and scan messages after it for [DONE:n] let executeIndex = -1; for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i] as { type: string; customType?: string }; - if (entry.customType === "plannotator-execute") { + if (entry.customType === 'plannotator-execute') { executeIndex = i; break; } @@ -641,8 +661,8 @@ Execute each step in order. After completing a step, include [DONE:n] in your re for (let i = executeIndex + 1; i < entries.length; i++) { const entry = entries[i]; if ( - entry.type === "message" && - "message" in entry && + entry.type === 'message' && + 'message' in entry && isAssistantMessage(entry.message as AgentMessage) ) { const text = getTextContent(entry.message as AssistantMessage); @@ -651,14 +671,14 @@ Execute each step in order. After completing a step, include [DONE:n] in your re } } else { // Plan file gone — fall back to idle - phase = "idle"; + phase = 'idle'; } } // Apply tool restrictions for current phase - if (phase === "planning") { + if (phase === 'planning') { pi.setActiveTools(PLANNING_TOOLS); - } else if (phase === "executing") { + } else if (phase === 'executing') { pi.setActiveTools(EXECUTION_TOOLS); } diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index 1fd15e2..a3a426c 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -14,9 +14,17 @@ "bugs": { "url": "https://github.com/backnotprop/plannotator/issues" }, - "keywords": ["pi-package", "plannotator", "plan-review", "ai-agent", "coding-agent"], + "keywords": [ + "pi-package", + "plannotator", + "plan-review", + "ai-agent", + "coding-agent" + ], "pi": { - "extensions": ["./"] + "extensions": [ + "./" + ] }, "files": [ "index.ts", diff --git a/apps/pi-extension/server.ts b/apps/pi-extension/server.ts index 75795a2..ba30781 100644 --- a/apps/pi-extension/server.ts +++ b/apps/pi-extension/server.ts @@ -6,19 +6,19 @@ * each UI needs — plan review, code review, and markdown annotation. */ -import { createServer, type IncomingMessage, type Server } from "node:http"; -import { execSync } from "node:child_process"; -import os from "node:os"; -import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { join, basename } from "node:path"; +import { execSync } from 'node:child_process'; +import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { createServer, type IncomingMessage, type Server } from 'node:http'; +import os from 'node:os'; +import { basename, join } from 'node:path'; // ── Helpers ────────────────────────────────────────────────────────────── function parseBody(req: IncomingMessage): Promise> { return new Promise((resolve) => { - let data = ""; - req.on("data", (chunk: string) => (data += chunk)); - req.on("end", () => { + let data = ''; + req.on('data', (chunk: string) => (data += chunk)); + req.on('end', () => { try { resolve(JSON.parse(data)); } catch { @@ -28,13 +28,13 @@ function parseBody(req: IncomingMessage): Promise> { }); } -function json(res: import("node:http").ServerResponse, data: unknown, status = 200): void { - res.writeHead(status, { "Content-Type": "application/json" }); +function json(res: import('node:http').ServerResponse, data: unknown, status = 200): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); } -function html(res: import("node:http").ServerResponse, content: string): void { - res.writeHead(200, { "Content-Type": "text/html" }); +function html(res: import('node:http').ServerResponse, content: string): void { + res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(content); } @@ -52,22 +52,24 @@ export function openBrowser(url: string): void { try { const browser = process.env.PLANNOTATOR_BROWSER || process.env.BROWSER; const platform = process.platform; - const wsl = platform === "linux" && os.release().toLowerCase().includes("microsoft"); + const wsl = platform === 'linux' && os.release().toLowerCase().includes('microsoft'); if (browser) { - if (process.env.PLANNOTATOR_BROWSER && platform === "darwin") { - execSync(`open -a ${JSON.stringify(browser)} ${JSON.stringify(url)}`, { stdio: "ignore" }); - } else if (platform === "win32" || wsl) { - execSync(`cmd.exe /c start "" ${JSON.stringify(browser)} ${JSON.stringify(url)}`, { stdio: "ignore" }); + if (process.env.PLANNOTATOR_BROWSER && platform === 'darwin') { + execSync(`open -a ${JSON.stringify(browser)} ${JSON.stringify(url)}`, { stdio: 'ignore' }); + } else if (platform === 'win32' || wsl) { + execSync(`cmd.exe /c start "" ${JSON.stringify(browser)} ${JSON.stringify(url)}`, { + stdio: 'ignore', + }); } else { - execSync(`${JSON.stringify(browser)} ${JSON.stringify(url)}`, { stdio: "ignore" }); + execSync(`${JSON.stringify(browser)} ${JSON.stringify(url)}`, { stdio: 'ignore' }); } - } else if (platform === "win32" || wsl) { - execSync(`cmd.exe /c start "" ${JSON.stringify(url)}`, { stdio: "ignore" }); - } else if (platform === "darwin") { - execSync(`open ${JSON.stringify(url)}`, { stdio: "ignore" }); + } else if (platform === 'win32' || wsl) { + execSync(`cmd.exe /c start "" ${JSON.stringify(url)}`, { stdio: 'ignore' }); + } else if (platform === 'darwin') { + execSync(`open ${JSON.stringify(url)}`, { stdio: 'ignore' }); } else { - execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: "ignore" }); + execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: 'ignore' }); } } catch { // Silently fail @@ -77,14 +79,14 @@ export function openBrowser(url: string): void { // ── Version History (Node-compatible, duplicated from packages/server) ── function sanitizeTag(name: string): string | null { - if (!name || typeof name !== "string") return null; + if (!name || typeof name !== 'string') return null; const sanitized = name .toLowerCase() .trim() - .replace(/[\s_]+/g, "-") - .replace(/[^a-z0-9-]/g, "") - .replace(/-+/g, "-") - .replace(/^-|-$/g, "") + .replace(/[\s_]+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') .slice(0, 30); return sanitized.length >= 2 ? sanitized : null; } @@ -96,7 +98,7 @@ function extractFirstHeading(markdown: string): string | null { } function generateSlug(plan: string): string { - const date = new Date().toISOString().split("T")[0]; + const date = new Date().toISOString().split('T')[0]; const heading = extractFirstHeading(plan); const slug = heading ? sanitizeTag(heading) : null; return slug ? `${slug}-${date}` : `plan-${date}`; @@ -104,25 +106,25 @@ function generateSlug(plan: string): string { function detectProjectName(): string { try { - const toplevel = execSync("git rev-parse --show-toplevel", { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], + const toplevel = execSync('git rev-parse --show-toplevel', { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], }).trim(); const name = basename(toplevel); - return sanitizeTag(name) ?? "_unknown"; + return sanitizeTag(name) ?? '_unknown'; } catch { // Not a git repo — fall back to cwd } try { const name = basename(process.cwd()); - return sanitizeTag(name) ?? "_unknown"; + return sanitizeTag(name) ?? '_unknown'; } catch { - return "_unknown"; + return '_unknown'; } } function getHistoryDir(project: string, slug: string): string { - const historyDir = join(os.homedir(), ".plannotator", "history", project, slug); + const historyDir = join(os.homedir(), '.plannotator', 'history', project, slug); mkdirSync(historyDir, { recursive: true }); return historyDir; } @@ -152,37 +154,35 @@ function saveToHistory( const historyDir = getHistoryDir(project, slug); const nextVersion = getNextVersionNumber(historyDir); if (nextVersion > 1) { - const latestPath = join(historyDir, `${String(nextVersion - 1).padStart(3, "0")}.md`); + const latestPath = join(historyDir, `${String(nextVersion - 1).padStart(3, '0')}.md`); try { - const existing = readFileSync(latestPath, "utf-8"); + const existing = readFileSync(latestPath, 'utf-8'); if (existing === plan) { return { version: nextVersion - 1, path: latestPath, isNew: false }; } - } catch { /* proceed with saving */ } + } catch { + /* proceed with saving */ + } } - const fileName = `${String(nextVersion).padStart(3, "0")}.md`; + const fileName = `${String(nextVersion).padStart(3, '0')}.md`; const filePath = join(historyDir, fileName); - writeFileSync(filePath, plan, "utf-8"); + writeFileSync(filePath, plan, 'utf-8'); return { version: nextVersion, path: filePath, isNew: true }; } -function getPlanVersion( - project: string, - slug: string, - version: number, -): string | null { - const historyDir = join(os.homedir(), ".plannotator", "history", project, slug); - const fileName = `${String(version).padStart(3, "0")}.md`; +function getPlanVersion(project: string, slug: string, version: number): string | null { + const historyDir = join(os.homedir(), '.plannotator', 'history', project, slug); + const fileName = `${String(version).padStart(3, '0')}.md`; const filePath = join(historyDir, fileName); try { - return readFileSync(filePath, "utf-8"); + return readFileSync(filePath, 'utf-8'); } catch { return null; } } function getVersionCount(project: string, slug: string): number { - const historyDir = join(os.homedir(), ".plannotator", "history", project, slug); + const historyDir = join(os.homedir(), '.plannotator', 'history', project, slug); try { const entries = readdirSync(historyDir); return entries.filter((e) => /^\d+\.md$/.test(e)).length; @@ -195,7 +195,7 @@ function listVersions( project: string, slug: string, ): Array<{ version: number; timestamp: string }> { - const historyDir = join(os.homedir(), ".plannotator", "history", project, slug); + const historyDir = join(os.homedir(), '.plannotator', 'history', project, slug); try { const entries = readdirSync(historyDir); const versions: Array<{ version: number; timestamp: string }> = []; @@ -208,7 +208,7 @@ function listVersions( const stat = statSync(filePath); versions.push({ version, timestamp: stat.mtime.toISOString() }); } catch { - versions.push({ version, timestamp: "" }); + versions.push({ version, timestamp: '' }); } } } @@ -221,7 +221,7 @@ function listVersions( function listProjectPlans( project: string, ): Array<{ slug: string; versions: number; lastModified: string }> { - const projectDir = join(os.homedir(), ".plannotator", "history", project); + const projectDir = join(os.homedir(), '.plannotator', 'history', project); try { const entries = readdirSync(projectDir, { withFileTypes: true }); const plans: Array<{ slug: string; versions: number; lastModified: string }> = []; @@ -235,12 +235,14 @@ function listProjectPlans( try { const mtime = statSync(join(slugDir, file)).mtime.getTime(); if (mtime > latest) latest = mtime; - } catch { /* skip */ } + } catch { + /* skip */ + } } plans.push({ slug: entry.name, versions: files.length, - lastModified: latest ? new Date(latest).toISOString() : "", + lastModified: latest ? new Date(latest).toISOString() : '', }); } return plans.sort((a, b) => b.lastModified.localeCompare(a.lastModified)); @@ -268,9 +270,7 @@ export function startPlanReviewServer(options: { const project = detectProjectName(); const historyResult = saveToHistory(project, slug, options.plan); const previousPlan = - historyResult.version > 1 - ? getPlanVersion(project, slug, historyResult.version - 1) - : null; + historyResult.version > 1 ? getPlanVersion(project, slug, historyResult.version - 1) : null; const versionInfo = { version: historyResult.version, totalVersions: getVersionCount(project, slug), @@ -285,36 +285,36 @@ export function startPlanReviewServer(options: { const server = createServer(async (req, res) => { const url = new URL(req.url!, `http://localhost`); - if (url.pathname === "/api/plan/version") { - const vParam = url.searchParams.get("v"); + if (url.pathname === '/api/plan/version') { + const vParam = url.searchParams.get('v'); if (!vParam) { - json(res, { error: "Missing v parameter" }, 400); + json(res, { error: 'Missing v parameter' }, 400); return; } const v = parseInt(vParam, 10); - if (isNaN(v) || v < 1) { - json(res, { error: "Invalid version number" }, 400); + if (Number.isNaN(v) || v < 1) { + json(res, { error: 'Invalid version number' }, 400); return; } const content = getPlanVersion(project, slug, v); if (content === null) { - json(res, { error: "Version not found" }, 404); + json(res, { error: 'Version not found' }, 404); return; } json(res, { plan: content, version: v }); - } else if (url.pathname === "/api/plan/versions") { + } else if (url.pathname === '/api/plan/versions') { json(res, { project, slug, versions: listVersions(project, slug) }); - } else if (url.pathname === "/api/plan/history") { + } else if (url.pathname === '/api/plan/history') { json(res, { project, plans: listProjectPlans(project) }); - } else if (url.pathname === "/api/plan") { - json(res, { plan: options.plan, origin: options.origin ?? "pi", previousPlan, versionInfo }); - } else if (url.pathname === "/api/approve" && req.method === "POST") { + } else if (url.pathname === '/api/plan') { + json(res, { plan: options.plan, origin: options.origin ?? 'pi', previousPlan, versionInfo }); + } else if (url.pathname === '/api/approve' && req.method === 'POST') { const body = await parseBody(req); resolveDecision({ approved: true, feedback: body.feedback as string | undefined }); json(res, { ok: true }); - } else if (url.pathname === "/api/deny" && req.method === "POST") { + } else if (url.pathname === '/api/deny' && req.method === 'POST') { const body = await parseBody(req); - resolveDecision({ approved: false, feedback: (body.feedback as string) || "Plan rejected" }); + resolveDecision({ approved: false, feedback: (body.feedback as string) || 'Plan rejected' }); json(res, { ok: true }); } else { html(res, options.htmlContent); @@ -333,10 +333,10 @@ export function startPlanReviewServer(options: { // ── Code Review Server ────────────────────────────────────────────────── -export type DiffType = "uncommitted" | "staged" | "unstaged" | "last-commit" | "branch"; +export type DiffType = 'uncommitted' | 'staged' | 'unstaged' | 'last-commit' | 'branch'; export interface DiffOption { - id: DiffType | "separator"; + id: DiffType | 'separator'; label: string; } @@ -356,50 +356,65 @@ export interface ReviewServerResult { /** Run a git command and return stdout (empty string on error). */ function git(cmd: string): string { try { - return execSync(`git ${cmd}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); + return execSync(`git ${cmd}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); } catch { - return ""; + return ''; } } export function getGitContext(): GitContext { - const currentBranch = git("rev-parse --abbrev-ref HEAD") || "HEAD"; + const currentBranch = git('rev-parse --abbrev-ref HEAD') || 'HEAD'; - let defaultBranch = ""; - const symRef = git("symbolic-ref refs/remotes/origin/HEAD"); + let defaultBranch = ''; + const symRef = git('symbolic-ref refs/remotes/origin/HEAD'); if (symRef) { - defaultBranch = symRef.replace("refs/remotes/origin/", ""); + defaultBranch = symRef.replace('refs/remotes/origin/', ''); } if (!defaultBranch) { - const hasMain = git("show-ref --verify refs/heads/main"); - defaultBranch = hasMain ? "main" : "master"; + const hasMain = git('show-ref --verify refs/heads/main'); + defaultBranch = hasMain ? 'main' : 'master'; } const diffOptions: DiffOption[] = [ - { id: "uncommitted", label: "Uncommitted changes" }, - { id: "last-commit", label: "Last commit" }, + { id: 'uncommitted', label: 'Uncommitted changes' }, + { id: 'last-commit', label: 'Last commit' }, ]; if (currentBranch !== defaultBranch) { - diffOptions.push({ id: "branch", label: `vs ${defaultBranch}` }); + diffOptions.push({ id: 'branch', label: `vs ${defaultBranch}` }); } return { currentBranch, defaultBranch, diffOptions }; } -export function runGitDiff(diffType: DiffType, defaultBranch = "main"): { patch: string; label: string } { +export function runGitDiff( + diffType: DiffType, + defaultBranch = 'main', +): { patch: string; label: string } { switch (diffType) { - case "uncommitted": - return { patch: git("diff HEAD --src-prefix=a/ --dst-prefix=b/"), label: "Uncommitted changes" }; - case "staged": - return { patch: git("diff --staged --src-prefix=a/ --dst-prefix=b/"), label: "Staged changes" }; - case "unstaged": - return { patch: git("diff --src-prefix=a/ --dst-prefix=b/"), label: "Unstaged changes" }; - case "last-commit": - return { patch: git("diff HEAD~1..HEAD --src-prefix=a/ --dst-prefix=b/"), label: "Last commit" }; - case "branch": - return { patch: git(`diff ${defaultBranch}..HEAD --src-prefix=a/ --dst-prefix=b/`), label: `Changes vs ${defaultBranch}` }; + case 'uncommitted': + return { + patch: git('diff HEAD --src-prefix=a/ --dst-prefix=b/'), + label: 'Uncommitted changes', + }; + case 'staged': + return { + patch: git('diff --staged --src-prefix=a/ --dst-prefix=b/'), + label: 'Staged changes', + }; + case 'unstaged': + return { patch: git('diff --src-prefix=a/ --dst-prefix=b/'), label: 'Unstaged changes' }; + case 'last-commit': + return { + patch: git('diff HEAD~1..HEAD --src-prefix=a/ --dst-prefix=b/'), + label: 'Last commit', + }; + case 'branch': + return { + patch: git(`diff ${defaultBranch}..HEAD --src-prefix=a/ --dst-prefix=b/`), + label: `Changes vs ${defaultBranch}`, + }; default: - return { patch: "", label: "Unknown diff type" }; + return { patch: '', label: 'Unknown diff type' }; } } @@ -413,7 +428,7 @@ export function startReviewServer(options: { }): ReviewServerResult { let currentPatch = options.rawPatch; let currentGitRef = options.gitRef; - let currentDiffType: DiffType = options.diffType || "uncommitted"; + let currentDiffType: DiffType = options.diffType || 'uncommitted'; let resolveDecision!: (result: { feedback: string }) => void; const decisionPromise = new Promise<{ feedback: string }>((r) => { @@ -423,30 +438,30 @@ export function startReviewServer(options: { const server = createServer(async (req, res) => { const url = new URL(req.url!, `http://localhost`); - if (url.pathname === "/api/diff" && req.method === "GET") { + if (url.pathname === '/api/diff' && req.method === 'GET') { json(res, { rawPatch: currentPatch, gitRef: currentGitRef, - origin: options.origin ?? "pi", + origin: options.origin ?? 'pi', diffType: currentDiffType, gitContext: options.gitContext, }); - } else if (url.pathname === "/api/diff/switch" && req.method === "POST") { + } else if (url.pathname === '/api/diff/switch' && req.method === 'POST') { const body = await parseBody(req); const newType = body.diffType as DiffType; if (!newType) { - json(res, { error: "Missing diffType" }, 400); + json(res, { error: 'Missing diffType' }, 400); return; } - const defaultBranch = options.gitContext?.defaultBranch || "main"; + const defaultBranch = options.gitContext?.defaultBranch || 'main'; const result = runGitDiff(newType, defaultBranch); currentPatch = result.patch; currentGitRef = result.label; currentDiffType = newType; json(res, { rawPatch: currentPatch, gitRef: currentGitRef, diffType: currentDiffType }); - } else if (url.pathname === "/api/feedback" && req.method === "POST") { + } else if (url.pathname === '/api/feedback' && req.method === 'POST') { const body = await parseBody(req); - resolveDecision({ feedback: (body.feedback as string) || "" }); + resolveDecision({ feedback: (body.feedback as string) || '' }); json(res, { ok: true }); } else { html(res, options.htmlContent); @@ -486,16 +501,16 @@ export function startAnnotateServer(options: { const server = createServer(async (req, res) => { const url = new URL(req.url!, `http://localhost`); - if (url.pathname === "/api/plan" && req.method === "GET") { + if (url.pathname === '/api/plan' && req.method === 'GET') { json(res, { plan: options.markdown, - origin: options.origin ?? "pi", - mode: "annotate", + origin: options.origin ?? 'pi', + mode: 'annotate', filePath: options.filePath, }); - } else if (url.pathname === "/api/feedback" && req.method === "POST") { + } else if (url.pathname === '/api/feedback' && req.method === 'POST') { const body = await parseBody(req); - resolveDecision({ feedback: (body.feedback as string) || "" }); + resolveDecision({ feedback: (body.feedback as string) || '' }); json(res, { ok: true }); } else { html(res, options.htmlContent); diff --git a/apps/pi-extension/utils.ts b/apps/pi-extension/utils.ts index eb570b4..51d542c 100644 --- a/apps/pi-extension/utils.ts +++ b/apps/pi-extension/utils.ts @@ -8,10 +8,22 @@ // ── Bash Safety ────────────────────────────────────────────────────────── const DESTRUCTIVE_PATTERNS = [ - /\brm\b/i, /\brmdir\b/i, /\bmv\b/i, /\bcp\b/i, /\bmkdir\b/i, - /\btouch\b/i, /\bchmod\b/i, /\bchown\b/i, /\bchgrp\b/i, /\bln\b/i, - /\btee\b/i, /\btruncate\b/i, /\bdd\b/i, /\bshred\b/i, - /(^|[^<])>(?!>)/, />>/, + /\brm\b/i, + /\brmdir\b/i, + /\bmv\b/i, + /\bcp\b/i, + /\bmkdir\b/i, + /\btouch\b/i, + /\bchmod\b/i, + /\bchown\b/i, + /\bchgrp\b/i, + /\bln\b/i, + /\btee\b/i, + /\btruncate\b/i, + /\bdd\b/i, + /\bshred\b/i, + /(^|[^<])>(?!>)/, + />>/, /\bnpm\s+(install|uninstall|update|ci|link|publish)/i, /\byarn\s+(add|remove|install|publish)/i, /\bpnpm\s+(add|remove|install|publish)/i, @@ -19,30 +31,69 @@ const DESTRUCTIVE_PATTERNS = [ /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i, /\bbrew\s+(install|uninstall|upgrade)/i, /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i, - /\bsudo\b/i, /\bsu\b/i, /\bkill\b/i, /\bpkill\b/i, /\bkillall\b/i, - /\breboot\b/i, /\bshutdown\b/i, + /\bsudo\b/i, + /\bsu\b/i, + /\bkill\b/i, + /\bpkill\b/i, + /\bkillall\b/i, + /\breboot\b/i, + /\bshutdown\b/i, /\bsystemctl\s+(start|stop|restart|enable|disable)/i, /\bservice\s+\S+\s+(start|stop|restart)/i, /\b(vim?|nano|emacs|code|subl)\b/i, ]; const SAFE_PATTERNS = [ - /^\s*cat\b/, /^\s*head\b/, /^\s*tail\b/, /^\s*less\b/, /^\s*more\b/, - /^\s*grep\b/, /^\s*find\b/, /^\s*ls\b/, /^\s*pwd\b/, /^\s*echo\b/, - /^\s*printf\b/, /^\s*wc\b/, /^\s*sort\b/, /^\s*uniq\b/, /^\s*diff\b/, - /^\s*file\b/, /^\s*stat\b/, /^\s*du\b/, /^\s*df\b/, /^\s*tree\b/, - /^\s*which\b/, /^\s*whereis\b/, /^\s*type\b/, /^\s*env\b/, - /^\s*printenv\b/, /^\s*uname\b/, /^\s*whoami\b/, /^\s*id\b/, - /^\s*date\b/, /^\s*cal\b/, /^\s*uptime\b/, /^\s*ps\b/, - /^\s*top\b/, /^\s*htop\b/, /^\s*free\b/, + /^\s*cat\b/, + /^\s*head\b/, + /^\s*tail\b/, + /^\s*less\b/, + /^\s*more\b/, + /^\s*grep\b/, + /^\s*find\b/, + /^\s*ls\b/, + /^\s*pwd\b/, + /^\s*echo\b/, + /^\s*printf\b/, + /^\s*wc\b/, + /^\s*sort\b/, + /^\s*uniq\b/, + /^\s*diff\b/, + /^\s*file\b/, + /^\s*stat\b/, + /^\s*du\b/, + /^\s*df\b/, + /^\s*tree\b/, + /^\s*which\b/, + /^\s*whereis\b/, + /^\s*type\b/, + /^\s*env\b/, + /^\s*printenv\b/, + /^\s*uname\b/, + /^\s*whoami\b/, + /^\s*id\b/, + /^\s*date\b/, + /^\s*cal\b/, + /^\s*uptime\b/, + /^\s*ps\b/, + /^\s*top\b/, + /^\s*htop\b/, + /^\s*free\b/, /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i, /^\s*git\s+ls-/i, /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i, /^\s*yarn\s+(list|info|why|audit)/i, - /^\s*node\s+--version/i, /^\s*python\s+--version/i, - /^\s*curl\s/i, /^\s*wget\s+-O\s*-/i, - /^\s*jq\b/, /^\s*sed\s+-n/i, /^\s*awk\b/, - /^\s*rg\b/, /^\s*fd\b/, /^\s*bat\b/, /^\s*exa\b/, + /^\s*node\s+--version/i, + /^\s*python\s+--version/i, + /^\s*curl\s/i, + /^\s*wget\s+-O\s*-/i, + /^\s*jq\b/, + /^\s*sed\s+-n/i, + /^\s*awk\b/, + /^\s*rg\b/, + /^\s*fd\b/, + /^\s*bat\b/, + /^\s*exa\b/, ]; export function isSafeCommand(command: string): boolean { @@ -73,7 +124,7 @@ export function parseChecklist(content: string): ChecklistItem[] { const pattern = /^[-*]\s*\[([ xX])\]\s+(.+)$/gm; for (const match of content.matchAll(pattern)) { - const completed = match[1] !== " "; + const completed = match[1] !== ' '; const text = match[2].trim(); if (text.length > 0) { items.push({ step: items.length + 1, text, completed }); @@ -84,7 +135,7 @@ export function parseChecklist(content: string): ChecklistItem[] { // ── Progress Tracking ──────────────────────────────────────────────────── -export function extractDoneSteps(message: string): number[] { +function extractDoneSteps(message: string): number[] { const steps: number[] = []; for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) { const step = Number(match[1]); diff --git a/apps/portal/index.tsx b/apps/portal/index.tsx index 6a8f679..ee05173 100644 --- a/apps/portal/index.tsx +++ b/apps/portal/index.tsx @@ -1,16 +1,16 @@ +import App from '@plannotator/editor'; import React from 'react'; import ReactDOM from 'react-dom/client'; -import App from '@plannotator/editor'; import '@plannotator/editor/styles'; const rootElement = document.getElementById('root'); if (!rootElement) { - throw new Error("Could not find root element to mount to"); + throw new Error('Could not find root element to mount to'); } const root = ReactDOM.createRoot(rootElement); root.render( - -); \ No newline at end of file + , +); diff --git a/apps/portal/tsconfig.json b/apps/portal/tsconfig.json index 93ef3e2..3628fab 100644 --- a/apps/portal/tsconfig.json +++ b/apps/portal/tsconfig.json @@ -4,15 +4,9 @@ "experimentalDecorators": true, "useDefineForClassFields": false, "module": "ESNext", - "lib": [ - "ES2022", - "DOM", - "DOM.Iterable" - ], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "skipLibCheck": true, - "types": [ - "node" - ], + "types": ["node"], "moduleResolution": "bundler", "isolatedModules": true, "moduleDetection": "force", @@ -27,4 +21,4 @@ "allowImportingTsExtensions": true, "noEmit": true } -} \ No newline at end of file +} diff --git a/apps/portal/vite.config.ts b/apps/portal/vite.config.ts index 822b099..1757405 100644 --- a/apps/portal/vite.config.ts +++ b/apps/portal/vite.config.ts @@ -1,7 +1,7 @@ -import path from 'path'; -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; +import path from 'node:path'; import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; import pkg from '../../package.json'; export default defineConfig({ @@ -19,7 +19,7 @@ export default defineConfig({ '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), '@plannotator/editor/styles': path.resolve(__dirname, '../../packages/editor/index.css'), '@plannotator/editor': path.resolve(__dirname, '../../packages/editor/App.tsx'), - } + }, }, build: { target: 'esnext', diff --git a/apps/review/index.tsx b/apps/review/index.tsx index ccd0f6d..dba864f 100644 --- a/apps/review/index.tsx +++ b/apps/review/index.tsx @@ -1,16 +1,16 @@ +import App from '@plannotator/review-editor'; import React from 'react'; import ReactDOM from 'react-dom/client'; -import App from '@plannotator/review-editor'; import '@plannotator/review-editor/styles'; const rootElement = document.getElementById('root'); if (!rootElement) { - throw new Error("Could not find root element to mount to"); + throw new Error('Could not find root element to mount to'); } const root = ReactDOM.createRoot(rootElement); root.render( - + , ); diff --git a/apps/review/server/index.ts b/apps/review/server/index.ts index 2c786bc..e742e44 100644 --- a/apps/review/server/index.ts +++ b/apps/review/server/index.ts @@ -15,50 +15,51 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { $ } from "bun"; -import { - startReviewServer, - handleReviewServerReady, -} from "@plannotator/server/review"; +import { handleReviewServerReady, startReviewServer } from '@plannotator/server/review'; +import { $ } from 'bun'; // Embed the built HTML at compile time -// @ts-ignore - Bun import attribute for text -import indexHtml from "../dist/index.html" with { type: "text" }; +// @ts-expect-error - Bun import attribute for text +import indexHtml from '../dist/index.html' with { type: 'text' }; + const htmlContent = indexHtml as unknown as string; // Parse CLI arguments const args = process.argv.slice(2); -const isStaged = args.includes("--staged"); -const gitRef = args.filter((arg) => arg !== "--staged").join(" ").trim(); +const isStaged = args.includes('--staged'); +const gitRef = args + .filter((arg) => arg !== '--staged') + .join(' ') + .trim(); // Build git diff command let diffCommand: string[]; if (isStaged) { - diffCommand = ["git", "diff", "--staged"]; + diffCommand = ['git', 'diff', '--staged']; } else if (gitRef) { - diffCommand = ["git", "diff", gitRef]; + diffCommand = ['git', 'diff', gitRef]; } else { - diffCommand = ["git", "diff"]; + diffCommand = ['git', 'diff']; } // Execute git diff -let rawPatch = ""; +let rawPatch = ''; try { const result = await $`${diffCommand}`.quiet(); rawPatch = result.text(); } catch (err) { - console.error("Failed to get git diff:", err); + console.error('Failed to get git diff:', err); process.exit(1); } // Determine display ref for UI let displayRef: string; if (isStaged) { - displayRef = "--staged"; + displayRef = '--staged'; } else if (gitRef) { displayRef = gitRef; } else { - displayRef = "working tree"; + displayRef = 'working tree'; } // Start the review server @@ -86,11 +87,15 @@ server.stop(); // Output the feedback as JSON console.log( - JSON.stringify({ - gitRef: displayRef, - feedback: result.feedback, - annotations: result.annotations, - }, null, 2) + JSON.stringify( + { + gitRef: displayRef, + feedback: result.feedback, + annotations: result.annotations, + }, + null, + 2, + ), ); process.exit(0); diff --git a/apps/review/tsconfig.json b/apps/review/tsconfig.json index ac2688e..7b219b8 100644 --- a/apps/review/tsconfig.json +++ b/apps/review/tsconfig.json @@ -4,15 +4,9 @@ "experimentalDecorators": true, "useDefineForClassFields": false, "module": "ESNext", - "lib": [ - "ES2022", - "DOM", - "DOM.Iterable" - ], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "skipLibCheck": true, - "types": [ - "node" - ], + "types": ["node"], "moduleResolution": "bundler", "isolatedModules": true, "moduleDetection": "force", diff --git a/apps/review/vite.config.ts b/apps/review/vite.config.ts index 72bedcb..2c6d47c 100644 --- a/apps/review/vite.config.ts +++ b/apps/review/vite.config.ts @@ -1,8 +1,8 @@ -import path from 'path'; -import { defineConfig } from 'vite'; +import path from 'node:path'; +import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; import { viteSingleFile } from 'vite-plugin-singlefile'; -import tailwindcss from '@tailwindcss/vite'; import pkg from '../../package.json'; export default defineConfig({ @@ -18,9 +18,12 @@ export default defineConfig({ alias: { '@': path.resolve(__dirname, '.'), '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), - '@plannotator/review-editor/styles': path.resolve(__dirname, '../../packages/review-editor/index.css'), + '@plannotator/review-editor/styles': path.resolve( + __dirname, + '../../packages/review-editor/index.css', + ), '@plannotator/review-editor': path.resolve(__dirname, '../../packages/review-editor/App.tsx'), - } + }, }, build: { target: 'esnext', diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000..62b5714 --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,54 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.5/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "formatter": { + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "rules": { + "recommended": true, + "complexity": { + "noImportantStyles": "off" + }, + "suspicious": { + "noArrayIndexKey": "warn", + "noExplicitAny": "warn", + "noAssignInExpressions": "error", + "useIterableCallbackReturn": "warn" + }, + "correctness": { + "useExhaustiveDependencies": "error", + "noUnusedFunctionParameters": "warn", + "noInvalidUseBeforeDeclaration": "error" + }, + "security": { + "noDangerouslySetInnerHtml": "warn" + }, + // TODO: re-enable a11y rules and fix ~311 findings (noSvgWithoutTitle, useButtonType, etc.) + "a11y": { + "recommended": false + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all" + } + }, + "css": { + "parser": { + "cssModules": false, + "tailwindDirectives": true + } + }, + "files": { + "includes": ["**", "!apps/marketing"] + } +} diff --git a/bun.lock b/bun.lock index abad462..818e2ed 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "plannotator", @@ -9,6 +8,7 @@ "diff": "^8.0.3", }, "devDependencies": { + "@biomejs/biome": "^2.4.5", "happy-dom": "^20.5.0", }, }, @@ -54,7 +54,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.9.3", + "version": "0.10.0", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -75,7 +75,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.9.3", + "version": "0.10.0", "peerDependencies": { "@mariozechner/pi-coding-agent": ">=0.53.0", }, @@ -143,7 +143,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.9.3", + "version": "0.10.0", "dependencies": { "@plannotator/shared": "workspace:*", }, @@ -302,6 +302,24 @@ "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@biomejs/biome": ["@biomejs/biome@2.4.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.5", "@biomejs/cli-darwin-x64": "2.4.5", "@biomejs/cli-linux-arm64": "2.4.5", "@biomejs/cli-linux-arm64-musl": "2.4.5", "@biomejs/cli-linux-x64": "2.4.5", "@biomejs/cli-linux-x64-musl": "2.4.5", "@biomejs/cli-win32-arm64": "2.4.5", "@biomejs/cli-win32-x64": "2.4.5" }, "bin": { "biome": "bin/biome" } }, "sha512-OWNCyMS0Q011R6YifXNOg6qsOg64IVc7XX6SqGsrGszPbkVCoaO7Sr/lISFnXZ9hjQhDewwZ40789QmrG0GYgQ=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lGS4Nd5O3KQJ6TeWv10mElnx1phERhBxqGP/IKq0SvZl78kcWDFMaTtVK+w3v3lusRFxJY78n07PbKplirsU5g=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6MoH4tyISIBNkZ2Q5T1R7dLd5BsITb2yhhhrU9jHZxnNSNMWl+s2Mxu7NBF8Y3a7JJcqq9nsk8i637z4gqkJxQ=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-iqLDgpzobG7gpBF0fwEVS/LT8kmN7+S0E2YKFDtqliJfzNLnAiV2Nnyb+ehCDCJgAZBASkYHR2o60VQWikpqIg=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Pmhv9zT95YzECfjEHNl3mN9Vhusw9VA5KHY0ZvlGsxsjwS5cb7vpRnHzJIv0vG7jB0JI7xEaMH9ddfZm/RozBw=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], diff --git a/package.json b/package.json index e669cb5..6a8e779 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "bugs": { "url": "https://github.com/backnotprop/plannotator/issues" }, - "workspaces": ["apps/*", "packages/*"], + "workspaces": [ + "apps/*", + "packages/*" + ], "scripts": { "dev:hook": "bun run --cwd apps/hook dev", "dev:portal": "bun run --cwd apps/portal dev", @@ -26,6 +29,10 @@ "build:review": "bun run --cwd apps/review build", "build:pi": "bun run build:review && bun run build:hook && bun run --cwd apps/pi-extension build", "build": "bun run build:hook && bun run build:opencode", + "lint": "biome lint .", + "format": "biome format .", + "format:fix": "biome format --write .", + "check": "biome check .", "test": "bun test" }, "dependencies": { @@ -33,6 +40,7 @@ "diff": "^8.0.3" }, "devDependencies": { + "@biomejs/biome": "^2.4.5", "happy-dom": "^20.5.0" } } diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 158ffaf..a440509 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -1,52 +1,62 @@ -import React, { useState, useEffect, useMemo, useRef } from 'react'; -import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, extractFrontmatter, Frontmatter } from '@plannotator/ui/utils/parser'; -import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer'; import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel'; +import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton'; +import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; +import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; import { ExportModal } from '@plannotator/ui/components/ExportModal'; +import { ImageAnnotator } from '@plannotator/ui/components/ImageAnnotator'; import { ImportModal } from '@plannotator/ui/components/ImportModal'; -import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; -import { Annotation, Block, EditorMode, type ImageAttachment } from '@plannotator/ui/types'; -import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; -import { ModeToggle } from '@plannotator/ui/components/ModeToggle'; import { ModeSwitcher } from '@plannotator/ui/components/ModeSwitcher'; -import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning'; -import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup'; +import { ModeToggle } from '@plannotator/ui/components/ModeToggle'; +import { PermissionModeSetup } from '@plannotator/ui/components/PermissionModeSetup'; +import { PlanDiffMarketing } from '@plannotator/ui/components/plan-diff/PlanDiffMarketing'; +import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher'; +import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer'; +import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; import { Settings } from '@plannotator/ui/components/Settings'; -import { useSharing } from '@plannotator/ui/hooks/useSharing'; -import { useAgents } from '@plannotator/ui/hooks/useAgents'; -import { useActiveSection } from '@plannotator/ui/hooks/useActiveSection'; -import { storage } from '@plannotator/ui/utils/storage'; -import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; +import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer'; +import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs'; +import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup'; +import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning'; +import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; +import { UIFeaturesSetup } from '@plannotator/ui/components/UIFeaturesSetup'; import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; -import { getObsidianSettings, getEffectiveVaultPath, isObsidianConfigured, CUSTOM_PATH_SENTINEL } from '@plannotator/ui/utils/obsidian'; +import { Viewer, type ViewerHandle } from '@plannotator/ui/components/Viewer'; +import { useActiveSection } from '@plannotator/ui/hooks/useActiveSection'; +import { useAgents } from '@plannotator/ui/hooks/useAgents'; +import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc'; +import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff'; +import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel'; +import { useSharing } from '@plannotator/ui/hooks/useSharing'; +import { useSidebar } from '@plannotator/ui/hooks/useSidebar'; +import { useVaultBrowser } from '@plannotator/ui/hooks/useVaultBrowser'; +import type { Annotation, Block, EditorMode, ImageAttachment } from '@plannotator/ui/types'; +import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; import { getBearSettings } from '@plannotator/ui/utils/bear'; import { getDefaultNotesApp } from '@plannotator/ui/utils/defaultNotesApp'; -import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; -import { getPlanSaveSettings } from '@plannotator/ui/utils/planSave'; -import { getUIPreferences, needsUIFeaturesSetup, type UIPreferences } from '@plannotator/ui/utils/uiPreferences'; import { getEditorMode, saveEditorMode } from '@plannotator/ui/utils/editorMode'; -import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel'; -import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; +import { + getEffectiveVaultPath, + getObsidianSettings, + isObsidianConfigured, + isVaultBrowserEnabled, +} from '@plannotator/ui/utils/obsidian'; +import { + exportAnnotations, + exportLinkedDocAnnotations, + extractFrontmatter, + type Frontmatter, + parseMarkdownToBlocks, +} from '@plannotator/ui/utils/parser'; import { getPermissionModeSettings, needsPermissionModeSetup, type PermissionMode, } from '@plannotator/ui/utils/permissionMode'; -import { PermissionModeSetup } from '@plannotator/ui/components/PermissionModeSetup'; -import { UIFeaturesSetup } from '@plannotator/ui/components/UIFeaturesSetup'; -import { PlanDiffMarketing } from '@plannotator/ui/components/plan-diff/PlanDiffMarketing'; import { needsPlanDiffMarketingDialog } from '@plannotator/ui/utils/planDiffMarketing'; -import { ImageAnnotator } from '@plannotator/ui/components/ImageAnnotator'; -import { deriveImageName } from '@plannotator/ui/components/AttachmentsButton'; -import { useSidebar } from '@plannotator/ui/hooks/useSidebar'; -import { usePlanDiff, type VersionInfo } from '@plannotator/ui/hooks/usePlanDiff'; -import { useLinkedDoc } from '@plannotator/ui/hooks/useLinkedDoc'; -import { useVaultBrowser } from '@plannotator/ui/hooks/useVaultBrowser'; -import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian'; -import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs'; -import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer'; -import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer'; -import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher'; +import { getPlanSaveSettings } from '@plannotator/ui/utils/planSave'; +import { storage } from '@plannotator/ui/utils/storage'; +import { getUIPreferences, needsUIFeaturesSetup } from '@plannotator/ui/utils/uiPreferences'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; const PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration @@ -364,10 +374,14 @@ const App: React.FC = () => { const [origin, setOrigin] = useState<'claude-code' | 'opencode' | 'pi' | null>(null); const [globalAttachments, setGlobalAttachments] = useState([]); const [annotateMode, setAnnotateMode] = useState(false); - const [isLoading, setIsLoading] = useState(true); + const [_isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [submitted, setSubmitted] = useState<'approved' | 'denied' | null>(null); - const [pendingPasteImage, setPendingPasteImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null); + const [pendingPasteImage, setPendingPasteImage] = useState<{ + file: File; + blobUrl: string; + initialName: string; + } | null>(null); const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false); const [showUIFeaturesSetup, setShowUIFeaturesSetup] = useState(false); const [showPlanDiffMarketing, setShowPlanDiffMarketing] = useState(false); @@ -378,7 +392,10 @@ const App: React.FC = () => { const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null); const [showExportDropdown, setShowExportDropdown] = useState(false); const [initialExportTab, setInitialExportTab] = useState<'share' | 'annotations' | 'notes'>(); - const [noteSaveToast, setNoteSaveToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + const [noteSaveToast, setNoteSaveToast] = useState<{ + type: 'success' | 'error'; + message: string; + } | null>(null); // Plan diff state const [isPlanDiffActive, setIsPlanDiffActive] = useState(false); const [planDiffMode, setPlanDiffMode] = useState('clean'); @@ -392,7 +409,10 @@ const App: React.FC = () => { const panelResize = useResizablePanel({ storageKey: 'plannotator-panel-width' }); const tocResize = useResizablePanel({ storageKey: 'plannotator-toc-width', - defaultWidth: 240, minWidth: 160, maxWidth: 400, side: 'left', + defaultWidth: 240, + minWidth: 160, + maxWidth: 400, + side: 'left', }); const isResizing = panelResize.isDragging || tocResize.isDragging; @@ -406,9 +426,10 @@ const App: React.FC = () => { } else { sidebar.close(); } - }, [uiPrefs.tocEnabled]); + }, [uiPrefs.tocEnabled, sidebar.open, sidebar.close]); // Clear diff view when switching away from versions tab + // biome-ignore lint/correctness/useExhaustiveDependencies: isPlanDiffActive is read, not a trigger useEffect(() => { if (sidebar.activeTab === 'toc' && isPlanDiffActive) { setIsPlanDiffActive(false); @@ -432,15 +453,24 @@ const App: React.FC = () => { // Linked document navigation const linkedDocHook = useLinkedDoc({ - markdown, annotations, selectedAnnotationId, globalAttachments, - setMarkdown, setAnnotations, setSelectedAnnotationId, setGlobalAttachments, - viewerRef, sidebar, + markdown, + annotations, + selectedAnnotationId, + globalAttachments, + setMarkdown, + setAnnotations, + setSelectedAnnotationId, + setGlobalAttachments, + viewerRef, + sidebar, }); // Obsidian vault browser const vaultBrowser = useVaultBrowser(); + // biome-ignore lint/correctness/useExhaustiveDependencies: recompute when user changes settings const showVaultTab = useMemo(() => isVaultBrowserEnabled(), [uiPrefs]); + // biome-ignore lint/correctness/useExhaustiveDependencies: recompute when user changes settings const vaultPath = useMemo(() => { if (!showVaultTab) return ''; const settings = getObsidianSettings(); @@ -450,11 +480,18 @@ const App: React.FC = () => { // Clear active file when vault browser is disabled useEffect(() => { if (!showVaultTab) vaultBrowser.setActiveFile(null); - }, [showVaultTab]); + }, [showVaultTab, vaultBrowser.setActiveFile]); // Auto-fetch vault tree when vault tab is first opened + // biome-ignore lint/correctness/useExhaustiveDependencies: vaultBrowser methods/state are read inside, not triggers useEffect(() => { - if (sidebar.activeTab === 'vault' && showVaultTab && vaultPath && vaultBrowser.tree.length === 0 && !vaultBrowser.isLoading) { + if ( + sidebar.activeTab === 'vault' && + showVaultTab && + vaultPath && + vaultBrowser.tree.length === 0 && + !vaultBrowser.isLoading + ) { vaultBrowser.fetchTree(vaultPath); } }, [sidebar.activeTab, showVaultTab, vaultPath]); @@ -462,23 +499,29 @@ const App: React.FC = () => { const buildVaultDocUrl = React.useCallback( (vp: string) => (path: string) => `/api/reference/obsidian/doc?vaultPath=${encodeURIComponent(vp)}&path=${encodeURIComponent(path)}`, - [] + [], ); // Vault file selection: open via linked doc system with vault endpoint - const handleVaultFileSelect = React.useCallback((relativePath: string) => { - linkedDocHook.open(relativePath, buildVaultDocUrl(vaultPath)); - vaultBrowser.setActiveFile(relativePath); - }, [vaultPath, linkedDocHook, vaultBrowser, buildVaultDocUrl]); + const handleVaultFileSelect = React.useCallback( + (relativePath: string) => { + linkedDocHook.open(relativePath, buildVaultDocUrl(vaultPath)); + vaultBrowser.setActiveFile(relativePath); + }, + [vaultPath, linkedDocHook, vaultBrowser, buildVaultDocUrl], + ); // Route linked doc opens through vault endpoint when viewing a vault file - const handleOpenLinkedDoc = React.useCallback((docPath: string) => { - if (vaultBrowser.activeFile && vaultPath) { - linkedDocHook.open(docPath, buildVaultDocUrl(vaultPath)); - } else { - linkedDocHook.open(docPath); - } - }, [vaultBrowser.activeFile, vaultPath, linkedDocHook, buildVaultDocUrl]); + const handleOpenLinkedDoc = React.useCallback( + (docPath: string) => { + if (vaultBrowser.activeFile && vaultPath) { + linkedDocHook.open(docPath, buildVaultDocUrl(vaultPath)); + } else { + linkedDocHook.open(docPath); + } + }, + [vaultBrowser.activeFile, vaultPath, linkedDocHook, buildVaultDocUrl], + ); // Wrap linked doc back to also clear vault active file const handleLinkedDocBack = React.useCallback(() => { @@ -491,7 +534,7 @@ const App: React.FC = () => { }, [vaultBrowser, vaultPath]); // Track active section for TOC highlighting - const headingCount = useMemo(() => blocks.filter(b => b.type === 'heading').length, [blocks]); + const headingCount = useMemo(() => blocks.filter((b) => b.type === 'heading').length, [blocks]); const activeSection = useActiveSection(containerRef, headingCount); // URL-based sharing @@ -504,7 +547,6 @@ const App: React.FC = () => { isGeneratingShortUrl, shortUrlError, pendingSharedAnnotations, - sharedGlobalAttachments, clearPendingSharedAnnotations, generateShortUrl, importFromShareUrl, @@ -522,11 +564,11 @@ const App: React.FC = () => { setIsLoading(false); }, shareBaseUrl, - pasteApiUrl + pasteApiUrl, ); // Fetch available agents for OpenCode (for validation on approve) - const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin); + const { getAgentWarning } = useAgents(origin); // Apply shared annotations to DOM after they're loaded useEffect(() => { @@ -559,49 +601,61 @@ const App: React.FC = () => { if (isSharedSession) return; // Already loaded from share fetch('/api/plan') - .then(res => { + .then((res) => { if (!res.ok) throw new Error('Not in API mode'); return res.json(); }) - .then((data: { plan: string; origin?: 'claude-code' | 'opencode' | 'pi'; mode?: 'annotate'; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string } }) => { - if (data.plan) setMarkdown(data.plan); - setIsApiMode(true); - if (data.mode === 'annotate') { - setAnnotateMode(true); - } - 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); - } - // Capture plan version history data - if (data.previousPlan !== undefined) { - setPreviousPlan(data.previousPlan); - } - if (data.versionInfo) { - setVersionInfo(data.versionInfo); - } - 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); - } else if (needsUIFeaturesSetup()) { - setShowUIFeaturesSetup(true); - } else if (needsPlanDiffMarketingDialog()) { - setShowPlanDiffMarketing(true); + .then( + (data: { + plan: string; + origin?: 'claude-code' | 'opencode' | 'pi'; + mode?: 'annotate'; + sharingEnabled?: boolean; + shareBaseUrl?: string; + pasteApiUrl?: string; + repoInfo?: { display: string; branch?: string }; + previousPlan?: string | null; + versionInfo?: { version: number; totalVersions: number; project: string }; + }) => { + if (data.plan) setMarkdown(data.plan); + setIsApiMode(true); + if (data.mode === 'annotate') { + setAnnotateMode(true); } - // Load saved permission mode preference - setPermissionMode(getPermissionModeSettings().mode); - } - }) + 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); + } + // Capture plan version history data + if (data.previousPlan !== undefined) { + setPreviousPlan(data.previousPlan); + } + if (data.versionInfo) { + setVersionInfo(data.versionInfo); + } + 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); + } else if (needsUIFeaturesSetup()) { + setShowUIFeaturesSetup(true); + } else if (needsPlanDiffMarketingDialog()) { + setShowPlanDiffMarketing(true); + } + // Load saved permission mode preference + setPermissionMode(getPermissionModeSettings().mode); + } + }, + ) .catch(() => { // Not in API mode - use default content setIsApiMode(false); @@ -627,7 +681,10 @@ const App: React.FC = () => { 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 }); } @@ -654,7 +711,7 @@ const App: React.FC = () => { 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 @@ -681,7 +738,14 @@ const App: React.FC = () => { const planSaveSettings = getPlanSaveSettings(); // Build request body - include integrations if enabled - const body: { obsidian?: object; bear?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {}; + const body: { + obsidian?: object; + bear?: object; + feedback?: string; + agentSwitch?: string; + planSave?: { enabled: boolean; customPath?: string }; + permissionMode?: string; + } = {}; // Include permission mode for Claude Code if (origin === 'claude-code') { @@ -706,7 +770,9 @@ const App: React.FC = () => { vaultPath: effectiveVaultPath, folder: obsidianSettings.folder || 'plannotator', plan: markdown, - ...(obsidianSettings.filenameFormat && { filenameFormat: obsidianSettings.filenameFormat }), + ...(obsidianSettings.filenameFormat && { + filenameFormat: obsidianSettings.filenameFormat, + }), }; } @@ -716,7 +782,7 @@ const App: React.FC = () => { // 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 + (d) => d.annotations.length > 0 || d.globalAttachments.length > 0, ); if (annotations.length > 0 || globalAttachments.length > 0 || hasDocAnnotations) { body.feedback = annotationsOutput; @@ -746,7 +812,7 @@ const App: React.FC = () => { enabled: planSaveSettings.enabled, ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), }, - }) + }), }); setSubmitted('denied'); } catch { @@ -773,6 +839,7 @@ const App: React.FC = () => { }; // Global keyboard shortcuts (Cmd/Ctrl+Enter to submit) + // biome-ignore lint/correctness/useExhaustiveDependencies: handler functions are stable enough — wrapping in useCallback would be a larger refactor useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Only handle Cmd/Ctrl+Enter @@ -783,8 +850,18 @@ const App: React.FC = () => { if (tag === 'INPUT' || tag === 'TEXTAREA') return; // Don't intercept if any modal is open - if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning || - showAgentWarning || showPermissionModeSetup || showUIFeaturesSetup || showPlanDiffMarketing || pendingPasteImage) return; + if ( + showExport || + showImport || + showFeedbackPrompt || + showClaudeCodeWarning || + showAgentWarning || + showPermissionModeSetup || + showUIFeaturesSetup || + showPlanDiffMarketing || + pendingPasteImage + ) + return; // Don't intercept if already submitted or submitting if (submitted || isSubmitting) return; @@ -827,46 +904,56 @@ const App: React.FC = () => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [ - showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning, - showPermissionModeSetup, showUIFeaturesSetup, showPlanDiffMarketing, pendingPasteImage, - submitted, isSubmitting, isApiMode, linkedDocHook.isActive, annotations.length, annotateMode, - origin, getAgentWarning, + showExport, + showImport, + showFeedbackPrompt, + showClaudeCodeWarning, + showAgentWarning, + showPermissionModeSetup, + showUIFeaturesSetup, + showPlanDiffMarketing, + pendingPasteImage, + submitted, + isSubmitting, + isApiMode, + linkedDocHook.isActive, + annotations.length, + annotateMode, + origin, + getAgentWarning, ]); const handleAddAnnotation = (ann: Annotation) => { - setAnnotations(prev => [...prev, ann]); + setAnnotations((prev) => [...prev, ann]); setSelectedAnnotationId(ann.id); setIsPanelOpen(true); }; const handleDeleteAnnotation = (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); }; const handleEditAnnotation = (id: string, updates: Partial) => { - setAnnotations(prev => prev.map(ann => - ann.id === id ? { ...ann, ...updates } : ann - )); + setAnnotations((prev) => prev.map((ann) => (ann.id === id ? { ...ann, ...updates } : ann))); }; const handleIdentityChange = (oldIdentity: string, newIdentity: string) => { - setAnnotations(prev => prev.map(ann => - ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann - )); + setAnnotations((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) => { + const handleTocNavigate = (_blockId: string) => { // Navigation handled by TableOfContents component // This is just a placeholder for future custom logic }; @@ -874,7 +961,7 @@ 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 = annotations.length > 0 || globalAttachments.length > 0; @@ -936,7 +1023,10 @@ const App: React.FC = () => { const data = await res.json(); const result = data.results?.[target]; if (result?.success) { - setNoteSaveToast({ type: 'success', message: `Saved to ${target === 'obsidian' ? 'Obsidian' : 'Bear'}` }); + setNoteSaveToast({ + type: 'success', + message: `Saved to ${target === 'obsidian' ? 'Obsidian' : 'Bear'}`, + }); } else { setNoteSaveToast({ type: 'error', message: result?.error || 'Save failed' }); } @@ -947,6 +1037,7 @@ const App: React.FC = () => { }; // Cmd/Ctrl+S keyboard shortcut — save to default notes app + // biome-ignore lint/correctness/useExhaustiveDependencies: handler functions are stable enough useEffect(() => { const handleSaveShortcut = (e: KeyboardEvent) => { if (e.key !== 's' || !(e.metaKey || e.ctrlKey)) return; @@ -954,8 +1045,17 @@ const App: React.FC = () => { const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; - if (showExport || showFeedbackPrompt || showClaudeCodeWarning || - showAgentWarning || showPermissionModeSetup || showUIFeaturesSetup || showPlanDiffMarketing || pendingPasteImage) return; + if ( + showExport || + showFeedbackPrompt || + showClaudeCodeWarning || + showAgentWarning || + showPermissionModeSetup || + showUIFeaturesSetup || + showPlanDiffMarketing || + pendingPasteImage + ) + return; if (submitted || !isApiMode) return; @@ -980,9 +1080,16 @@ const App: React.FC = () => { window.addEventListener('keydown', handleSaveShortcut); return () => window.removeEventListener('keydown', handleSaveShortcut); }, [ - showExport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning, - showPermissionModeSetup, showUIFeaturesSetup, showPlanDiffMarketing, pendingPasteImage, - submitted, isApiMode, markdown, annotationsOutput, + showExport, + showFeedbackPrompt, + showClaudeCodeWarning, + showAgentWarning, + showPermissionModeSetup, + showUIFeaturesSetup, + showPlanDiffMarketing, + pendingPasteImage, + submitted, + isApiMode, ]); // Close export dropdown on click outside @@ -1030,13 +1137,15 @@ const App: React.FC = () => { v{typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'} {origin && ( -