From 0916809aa77319eeedc1113fa441ef2c46a731ef Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 3 Mar 2026 16:00:58 -0800 Subject: [PATCH 1/9] refactor: extract shared server handlers and reference routes Extract duplicated image/upload/agents handlers into shared-handlers.ts and doc/vault reference endpoints into reference-handlers.ts. Unifies the three identical handleServerReady functions. Closes #210. Co-Authored-By: Claude Opus 4.6 --- packages/server/annotate.ts | 58 +----- packages/server/index.ts | 276 +------------------------- packages/server/reference-handlers.ts | 217 ++++++++++++++++++++ packages/server/review.ts | 80 +------- packages/server/shared-handlers.ts | 105 ++++++++++ 5 files changed, 344 insertions(+), 392 deletions(-) create mode 100644 packages/server/reference-handlers.ts create mode 100644 packages/server/shared-handlers.ts diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 6803c70..12d9c11 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -11,15 +11,14 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { mkdirSync } from "fs"; import { isRemoteSession, getServerPort } from "./remote"; -import { openBrowser } from "./browser"; import { getRepoInfo } from "./repo"; -import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image"; +import { handleImageRequest, handleUploadRequest } from "./shared-handlers"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; export { openBrowser } from "./browser"; +export { handleServerReady as handleAnnotateServerReady } from "./shared-handlers"; // --- Types --- @@ -126,48 +125,12 @@ export async function startAnnotateServer( // API: Serve images (local paths or temp uploads) if (url.pathname === "/api/image") { - const imagePath = url.searchParams.get("path"); - if (!imagePath) { - return new Response("Missing path parameter", { status: 400 }); - } - const validation = validateImagePath(imagePath); - if (!validation.valid) { - return new Response(validation.error!, { status: 403 }); - } - try { - const file = Bun.file(validation.resolved); - if (!(await file.exists())) { - return new Response("File not found", { status: 404 }); - } - return new Response(file); - } catch { - return new Response("Failed to read file", { status: 500 }); - } + return handleImageRequest(url); } // API: Upload image -> save to temp -> return path if (url.pathname === "/api/upload" && req.method === "POST") { - try { - const formData = await req.formData(); - const file = formData.get("file") as File; - if (!file) { - return new Response("No file provided", { status: 400 }); - } - - const extResult = validateUploadExtension(file.name); - if (!extResult.valid) { - return Response.json({ error: extResult.error }, { status: 400 }); - } - mkdirSync(UPLOAD_DIR, { recursive: true }); - const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`; - - await Bun.write(tempPath, file); - return Response.json({ path: tempPath, originalName: file.name }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Upload failed"; - return Response.json({ error: message }, { status: 500 }); - } + return handleUploadRequest(req); } // API: Submit annotation feedback @@ -242,16 +205,3 @@ export async function startAnnotateServer( stop: () => server.stop(), }; } - -/** - * Default behavior: open browser for local sessions - */ -export async function handleAnnotateServerReady( - url: string, - isRemote: boolean, - _port: number -): Promise { - if (!isRemote) { - await openBrowser(url); - } -} diff --git a/packages/server/index.ts b/packages/server/index.ts index e2aecbb..d45191d 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -9,12 +9,10 @@ * PLANNOTATOR_ORIGIN - Origin identifier ("claude-code" or "opencode") */ -import { mkdirSync, existsSync, statSync } from "fs"; -import { resolve } from "path"; import { isRemoteSession, getServerPort } from "./remote"; -import { openBrowser } from "./browser"; -import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image"; import { openEditorDiff } from "./ide"; +import { handleImageRequest, handleUploadRequest, handleAgentsRequest, handleServerReady } from "./shared-handlers"; +import { handleDocRequest, handleVaultFilesRequest, handleVaultDocRequest } from "./reference-handlers"; import { detectObsidianVaults, saveToObsidian, @@ -25,7 +23,6 @@ import { } from "./integrations"; import { generateSlug, - savePlan, saveAnnotations, saveFinalSnapshot, saveToHistory, @@ -41,6 +38,8 @@ import { detectProjectName } from "./project"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; export { openBrowser } from "./browser"; +export { handleServerReady } from "./shared-handlers"; +export type { OpencodeClient } from "./shared-handlers"; export * from "./integrations"; export * from "./storage"; @@ -64,11 +63,7 @@ export interface ServerOptions { /** Called when server starts with the URL, remote status, and port */ onReady?: (url: string, isRemote: boolean, port: number) => void; /** OpenCode client for querying available agents (OpenCode only) */ - opencodeClient?: { - app: { - agents: (options?: object) => Promise<{ data?: Array<{ name: string; description?: string; mode: string; hidden?: boolean }> }>; - }; - }; + opencodeClient?: OpencodeClient; } export interface ServerResult { @@ -203,120 +198,17 @@ export async function startPlannotatorServer( // API: Serve a linked markdown document if (url.pathname === "/api/doc" && req.method === "GET") { - const requestedPath = url.searchParams.get("path"); - if (!requestedPath) { - return Response.json({ error: "Missing path parameter" }, { status: 400 }); - } - - const projectRoot = process.cwd(); - - // Restrict to markdown files only - if (!/\.mdx?$/i.test(requestedPath)) { - return Response.json({ error: "Only .md and .mdx files are supported" }, { status: 400 }); - } - - // Path resolution: 3 strategies in order - let resolvedPath: string | null = null; - - if (requestedPath.startsWith("/")) { - // 1. Absolute path - resolvedPath = requestedPath; - } else { - // 2. Relative to project root - const fromRoot = resolve(projectRoot, requestedPath); - if (await Bun.file(fromRoot).exists()) { - resolvedPath = fromRoot; - } - - // 3. Bare filename — search entire project for unique match - if (!resolvedPath && !requestedPath.includes("/")) { - const glob = new Bun.Glob(`**/${requestedPath}`); - const matches: string[] = []; - for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) { - if (match.includes("node_modules/") || match.includes(".git/")) continue; - if (match.split("/").pop() === requestedPath) { - matches.push(resolve(projectRoot, match)); - } - } - if (matches.length === 1) { - resolvedPath = matches[0]; - } else if (matches.length > 1) { - const relativePaths = matches.map((m) => m.replace(projectRoot + "/", "")); - return Response.json( - { error: `Ambiguous filename '${requestedPath}': found ${matches.length} matches`, matches: relativePaths }, - { status: 400 } - ); - } - } - } - - if (!resolvedPath) { - return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 }); - } - - // Security: path must stay within projectRoot - const normalised = resolve(resolvedPath); - if (!normalised.startsWith(projectRoot + "/") && normalised !== projectRoot) { - return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 }); - } - - const file = Bun.file(normalised); - if (!(await file.exists())) { - return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 }); - } - - try { - const markdown = await file.text(); - return Response.json({ markdown, filepath: normalised }); - } catch { - return Response.json({ error: "Failed to read file" }, { status: 500 }); - } + return handleDocRequest(url); } // API: Serve images (local paths or temp uploads) if (url.pathname === "/api/image") { - const imagePath = url.searchParams.get("path"); - if (!imagePath) { - return new Response("Missing path parameter", { status: 400 }); - } - const validation = validateImagePath(imagePath); - if (!validation.valid) { - return new Response(validation.error!, { status: 403 }); - } - try { - const file = Bun.file(validation.resolved); - if (!(await file.exists())) { - return new Response("File not found", { status: 404 }); - } - return new Response(file); - } catch { - return new Response("Failed to read file", { status: 500 }); - } + return handleImageRequest(url); } // API: Upload image -> save to temp -> return path if (url.pathname === "/api/upload" && req.method === "POST") { - try { - const formData = await req.formData(); - const file = formData.get("file") as File; - if (!file) { - return new Response("No file provided", { status: 400 }); - } - - const extResult = validateUploadExtension(file.name); - if (!extResult.valid) { - return Response.json({ error: extResult.error }, { status: 400 }); - } - mkdirSync(UPLOAD_DIR, { recursive: true }); - const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`; - - await Bun.write(tempPath, file); - return Response.json({ path: tempPath, originalName: file.name }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Upload failed"; - return Response.json({ error: message }, { status: 500 }); - } + return handleUploadRequest(req); } // API: Open plan diff in VS Code @@ -352,98 +244,17 @@ export async function startPlannotatorServer( // API: List Obsidian vault files as a tree if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") { - const vaultPath = url.searchParams.get("vaultPath"); - if (!vaultPath) { - return Response.json({ error: "Missing vaultPath parameter" }, { status: 400 }); - } - - const resolvedVault = resolve(vaultPath); - if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) { - return Response.json({ error: "Invalid vault path" }, { status: 400 }); - } - - try { - const glob = new Bun.Glob("**/*.md"); - const files: string[] = []; - for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) { - if (match.includes(".obsidian/") || match.includes(".trash/")) continue; - files.push(match); - } - files.sort(); - - const tree = buildFileTree(files); - return Response.json({ tree }); - } catch { - return Response.json({ error: "Failed to list vault files" }, { status: 500 }); - } + return handleVaultFilesRequest(url); } // API: Read an Obsidian vault document if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") { - const vaultPath = url.searchParams.get("vaultPath"); - const filePath = url.searchParams.get("path"); - if (!vaultPath || !filePath) { - return Response.json({ error: "Missing vaultPath or path parameter" }, { status: 400 }); - } - if (!/\.mdx?$/i.test(filePath)) { - return Response.json({ error: "Only markdown files are supported" }, { status: 400 }); - } - - const resolvedVault = resolve(vaultPath); - let resolvedFile = resolve(resolvedVault, filePath); - - // If direct path doesn't exist and it's a bare filename, search the vault - if (!existsSync(resolvedFile) && !filePath.includes("/")) { - const glob = new Bun.Glob(`**/${filePath}`); - const matches: string[] = []; - for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) { - if (match.includes(".obsidian/") || match.includes(".trash/")) continue; - matches.push(resolve(resolvedVault, match)); - } - if (matches.length === 1) { - resolvedFile = matches[0]; - } else if (matches.length > 1) { - const relativePaths = matches.map((m) => m.replace(resolvedVault + "/", "")); - return Response.json( - { error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, matches: relativePaths }, - { status: 400 } - ); - } - } - - // Security: must be within vault - if (!resolvedFile.startsWith(resolvedVault + "/")) { - return Response.json({ error: "Access denied: path is outside vault" }, { status: 403 }); - } - - try { - const file = Bun.file(resolvedFile); - if (!(await file.exists())) { - return Response.json({ error: `File not found: ${filePath}` }, { status: 404 }); - } - const markdown = await file.text(); - return Response.json({ markdown, filepath: resolvedFile }); - } catch { - return Response.json({ error: "Failed to read file" }, { status: 500 }); - } + return handleVaultDocRequest(url); } // API: Get available agents (OpenCode only) if (url.pathname === "/api/agents") { - if (!options.opencodeClient) { - return Response.json({ agents: [] }); - } - - try { - const result = await options.opencodeClient.app.agents({}); - const agents = (result.data ?? []) - .filter((a) => a.mode === "primary" && !a.hidden) - .map((a) => ({ id: a.name, name: a.name, description: a.description })); - - return Response.json({ agents }); - } catch { - return Response.json({ agents: [], error: "Failed to fetch agents" }); - } + return handleAgentsRequest(options.opencodeClient); } // API: Save to notes (decoupled from approve/deny) @@ -638,68 +449,3 @@ export async function startPlannotatorServer( }; } -/** - * Default behavior: open browser for local sessions - */ -export async function handleServerReady( - url: string, - isRemote: boolean, - _port: number -): Promise { - if (!isRemote) { - await openBrowser(url); - } -} - -// --- Vault file tree helpers --- - -export interface VaultNode { - name: string; - path: string; // relative path within vault - type: "file" | "folder"; - children?: VaultNode[]; -} - -/** - * Build a nested file tree from a sorted list of relative paths. - * Folders are sorted before files at each level. - */ -function buildFileTree(relativePaths: string[]): VaultNode[] { - const root: VaultNode[] = []; - - for (const filePath of relativePaths) { - const parts = filePath.split("/"); - let current = root; - let pathSoFar = ""; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part; - const isFile = i === parts.length - 1; - - let node = current.find((n) => n.name === part && n.type === (isFile ? "file" : "folder")); - if (!node) { - node = { name: part, path: pathSoFar, type: isFile ? "file" : "folder" }; - if (!isFile) node.children = []; - current.push(node); - } - if (!isFile) { - current = node.children!; - } - } - } - - // Sort: folders first (alphabetical), then files (alphabetical) - const sortNodes = (nodes: VaultNode[]) => { - nodes.sort((a, b) => { - if (a.type !== b.type) return a.type === "folder" ? -1 : 1; - return a.name.localeCompare(b.name); - }); - for (const node of nodes) { - if (node.children) sortNodes(node.children); - } - }; - sortNodes(root); - - return root; -} diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts new file mode 100644 index 0000000..df413be --- /dev/null +++ b/packages/server/reference-handlers.ts @@ -0,0 +1,217 @@ +/** + * Reference/Doc Route Handlers + * + * Serves linked markdown documents and Obsidian vault file trees. + * Used only by the plan server, but extracted here because they have + * no closure dependencies on server state. + */ + +import { existsSync, statSync } from "fs"; +import { resolve } from "path"; + +// --- Types --- + +interface VaultNode { + name: string; + path: string; // relative path within vault + type: "file" | "folder"; + children?: VaultNode[]; +} + +// --- Handlers --- + +/** Serve a linked markdown document from the project */ +export async function handleDocRequest(url: URL): Promise { + const requestedPath = url.searchParams.get("path"); + if (!requestedPath) { + return Response.json({ error: "Missing path parameter" }, { status: 400 }); + } + + const projectRoot = process.cwd(); + + // Restrict to markdown files only + if (!/\.mdx?$/i.test(requestedPath)) { + return Response.json({ error: "Only .md and .mdx files are supported" }, { status: 400 }); + } + + // Path resolution: 3 strategies in order + let resolvedPath: string | null = null; + + if (requestedPath.startsWith("/")) { + // 1. Absolute path + resolvedPath = requestedPath; + } else { + // 2. Relative to project root + const fromRoot = resolve(projectRoot, requestedPath); + if (await Bun.file(fromRoot).exists()) { + resolvedPath = fromRoot; + } + + // 3. Bare filename — search entire project for unique match + if (!resolvedPath && !requestedPath.includes("/")) { + const glob = new Bun.Glob(`**/${requestedPath}`); + const matches: string[] = []; + for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) { + if (match.includes("node_modules/") || match.includes(".git/")) continue; + if (match.split("/").pop() === requestedPath) { + matches.push(resolve(projectRoot, match)); + } + } + if (matches.length === 1) { + resolvedPath = matches[0]; + } else if (matches.length > 1) { + const relativePaths = matches.map((m) => m.replace(projectRoot + "/", "")); + return Response.json( + { error: `Ambiguous filename '${requestedPath}': found ${matches.length} matches`, matches: relativePaths }, + { status: 400 } + ); + } + } + } + + if (!resolvedPath) { + return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 }); + } + + // Security: path must stay within projectRoot + const normalised = resolve(resolvedPath); + if (!normalised.startsWith(projectRoot + "/") && normalised !== projectRoot) { + return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 }); + } + + const file = Bun.file(normalised); + if (!(await file.exists())) { + return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 }); + } + + try { + const markdown = await file.text(); + return Response.json({ markdown, filepath: normalised }); + } catch { + return Response.json({ error: "Failed to read file" }, { status: 500 }); + } +} + +/** List Obsidian vault files as a nested tree */ +export async function handleVaultFilesRequest(url: URL): Promise { + const vaultPath = url.searchParams.get("vaultPath"); + if (!vaultPath) { + return Response.json({ error: "Missing vaultPath parameter" }, { status: 400 }); + } + + const resolvedVault = resolve(vaultPath); + if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) { + return Response.json({ error: "Invalid vault path" }, { status: 400 }); + } + + try { + const glob = new Bun.Glob("**/*.md"); + const files: string[] = []; + for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) { + if (match.includes(".obsidian/") || match.includes(".trash/")) continue; + files.push(match); + } + files.sort(); + + const tree = buildFileTree(files); + return Response.json({ tree }); + } catch { + return Response.json({ error: "Failed to list vault files" }, { status: 500 }); + } +} + +/** Read a single Obsidian vault document */ +export async function handleVaultDocRequest(url: URL): Promise { + const vaultPath = url.searchParams.get("vaultPath"); + const filePath = url.searchParams.get("path"); + if (!vaultPath || !filePath) { + return Response.json({ error: "Missing vaultPath or path parameter" }, { status: 400 }); + } + if (!/\.mdx?$/i.test(filePath)) { + return Response.json({ error: "Only markdown files are supported" }, { status: 400 }); + } + + const resolvedVault = resolve(vaultPath); + let resolvedFile = resolve(resolvedVault, filePath); + + // If direct path doesn't exist and it's a bare filename, search the vault + if (!existsSync(resolvedFile) && !filePath.includes("/")) { + const glob = new Bun.Glob(`**/${filePath}`); + const matches: string[] = []; + for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) { + if (match.includes(".obsidian/") || match.includes(".trash/")) continue; + matches.push(resolve(resolvedVault, match)); + } + if (matches.length === 1) { + resolvedFile = matches[0]; + } else if (matches.length > 1) { + const relativePaths = matches.map((m) => m.replace(resolvedVault + "/", "")); + return Response.json( + { error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, matches: relativePaths }, + { status: 400 } + ); + } + } + + // Security: must be within vault + if (!resolvedFile.startsWith(resolvedVault + "/")) { + return Response.json({ error: "Access denied: path is outside vault" }, { status: 403 }); + } + + try { + const file = Bun.file(resolvedFile); + if (!(await file.exists())) { + return Response.json({ error: `File not found: ${filePath}` }, { status: 404 }); + } + const markdown = await file.text(); + return Response.json({ markdown, filepath: resolvedFile }); + } catch { + return Response.json({ error: "Failed to read file" }, { status: 500 }); + } +} + +// --- Helpers --- + +/** + * Build a nested file tree from a sorted list of relative paths. + * Folders are sorted before files at each level. + */ +function buildFileTree(relativePaths: string[]): VaultNode[] { + const root: VaultNode[] = []; + + for (const filePath of relativePaths) { + const parts = filePath.split("/"); + let current = root; + let pathSoFar = ""; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part; + const isFile = i === parts.length - 1; + + let node = current.find((n) => n.name === part && n.type === (isFile ? "file" : "folder")); + if (!node) { + node = { name: part, path: pathSoFar, type: isFile ? "file" : "folder" }; + if (!isFile) node.children = []; + current.push(node); + } + if (!isFile) { + current = node.children!; + } + } + } + + // Sort: folders first (alphabetical), then files (alphabetical) + const sortNodes = (nodes: VaultNode[]) => { + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === "folder" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const node of nodes) { + if (node.children) sortNodes(node.children); + } + }; + sortNodes(root); + + return root; +} diff --git a/packages/server/review.ts b/packages/server/review.ts index 019bc53..b24486c 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -9,17 +9,17 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { mkdirSync } from "fs"; import { isRemoteSession, getServerPort } from "./remote"; -import { openBrowser } from "./browser"; import { type DiffType, type GitContext, runGitDiff } from "./git"; import { getRepoInfo } from "./repo"; -import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image"; +import { handleImageRequest, handleUploadRequest, handleAgentsRequest, handleServerReady } from "./shared-handlers"; +import type { OpencodeClient } from "./shared-handlers"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; export { openBrowser } from "./browser"; export { type DiffType, type DiffOption, type GitContext } from "./git"; +export { handleServerReady as handleReviewServerReady } from "./shared-handlers"; // --- Types --- @@ -45,11 +45,7 @@ export interface ReviewServerOptions { /** Called when server starts with the URL, remote status, and port */ onReady?: (url: string, isRemote: boolean, port: number) => void; /** OpenCode client for querying available agents (OpenCode only) */ - opencodeClient?: { - app: { - agents: (options?: object) => Promise<{ data?: Array<{ name: string; description?: string; mode: string; hidden?: boolean }> }>; - }; - }; + opencodeClient?: OpencodeClient; } export interface ReviewServerResult { @@ -177,66 +173,17 @@ export async function startReviewServer( // API: Serve images (local paths or temp uploads) if (url.pathname === "/api/image") { - const imagePath = url.searchParams.get("path"); - if (!imagePath) { - return new Response("Missing path parameter", { status: 400 }); - } - const validation = validateImagePath(imagePath); - if (!validation.valid) { - return new Response(validation.error!, { status: 403 }); - } - try { - const file = Bun.file(validation.resolved); - if (!(await file.exists())) { - return new Response("File not found", { status: 404 }); - } - return new Response(file); - } catch { - return new Response("Failed to read file", { status: 500 }); - } + return handleImageRequest(url); } // API: Upload image -> save to temp -> return path if (url.pathname === "/api/upload" && req.method === "POST") { - try { - const formData = await req.formData(); - const file = formData.get("file") as File; - if (!file) { - return new Response("No file provided", { status: 400 }); - } - - const extResult = validateUploadExtension(file.name); - if (!extResult.valid) { - return Response.json({ error: extResult.error }, { status: 400 }); - } - mkdirSync(UPLOAD_DIR, { recursive: true }); - const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`; - - await Bun.write(tempPath, file); - return Response.json({ path: tempPath, originalName: file.name }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Upload failed"; - return Response.json({ error: message }, { status: 500 }); - } + return handleUploadRequest(req); } // API: Get available agents (OpenCode only) if (url.pathname === "/api/agents") { - if (!options.opencodeClient) { - return Response.json({ agents: [] }); - } - - try { - const result = await options.opencodeClient.app.agents({}); - const agents = (result.data ?? []) - .filter((a) => a.mode === "primary" && !a.hidden) - .map((a) => ({ id: a.name, name: a.name, description: a.description })); - - return Response.json({ agents }); - } catch { - return Response.json({ agents: [], error: "Failed to fetch agents" }); - } + return handleAgentsRequest(options.opencodeClient); } // API: Submit review feedback @@ -307,16 +254,3 @@ export async function startReviewServer( stop: () => server.stop(), }; } - -/** - * Default behavior: open browser for local sessions - */ -export async function handleReviewServerReady( - url: string, - isRemote: boolean, - _port: number -): Promise { - if (!isRemote) { - await openBrowser(url); - } -} diff --git a/packages/server/shared-handlers.ts b/packages/server/shared-handlers.ts new file mode 100644 index 0000000..3d46807 --- /dev/null +++ b/packages/server/shared-handlers.ts @@ -0,0 +1,105 @@ +/** + * Shared Route Handlers + * + * Image serving, file upload, agent listing, and server-ready behavior + * shared across all three servers (plan, review, annotate). + */ + +import { mkdirSync } from "fs"; +import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image"; +import { openBrowser } from "./browser"; + +// --- Types --- + +/** Shape of the OpenCode client used for agent listing */ +export interface OpencodeClient { + app: { + agents: (options?: object) => Promise<{ + data?: Array<{ + name: string; + description?: string; + mode: string; + hidden?: boolean; + }>; + }>; + }; +} + +// --- Handlers --- + +/** Serve images from local paths or temp uploads */ +export async function handleImageRequest(url: URL): Promise { + const imagePath = url.searchParams.get("path"); + if (!imagePath) { + return new Response("Missing path parameter", { status: 400 }); + } + const validation = validateImagePath(imagePath); + if (!validation.valid) { + return new Response(validation.error!, { status: 403 }); + } + try { + const file = Bun.file(validation.resolved); + if (!(await file.exists())) { + return new Response("File not found", { status: 404 }); + } + return new Response(file); + } catch { + return new Response("Failed to read file", { status: 500 }); + } +} + +/** Upload image to temp directory, return path */ +export async function handleUploadRequest(req: Request): Promise { + try { + const formData = await req.formData(); + const file = formData.get("file") as File; + if (!file) { + return new Response("No file provided", { status: 400 }); + } + + const extResult = validateUploadExtension(file.name); + if (!extResult.valid) { + return Response.json({ error: extResult.error }, { status: 400 }); + } + mkdirSync(UPLOAD_DIR, { recursive: true }); + const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`; + + await Bun.write(tempPath, file); + return Response.json({ path: tempPath, originalName: file.name }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Upload failed"; + return Response.json({ error: message }, { status: 500 }); + } +} + +/** List available agents (OpenCode only) */ +export async function handleAgentsRequest( + opencodeClient?: OpencodeClient +): Promise { + if (!opencodeClient) { + return Response.json({ agents: [] }); + } + + try { + const result = await opencodeClient.app.agents({}); + const agents = (result.data ?? []) + .filter((a) => a.mode === "primary" && !a.hidden) + .map((a) => ({ id: a.name, name: a.name, description: a.description })); + + return Response.json({ agents }); + } catch { + return Response.json({ agents: [], error: "Failed to fetch agents" }); + } +} + +/** Open browser for local sessions (shared across all servers) */ +export async function handleServerReady( + url: string, + isRemote: boolean, + _port: number +): Promise { + if (!isRemote) { + await openBrowser(url); + } +} From 7a8d9df0be631360e3e33762e3fd585761b18254 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 3 Mar 2026 16:14:17 -0800 Subject: [PATCH 2/9] refactor: extract shared CSS theme and formatTimestamp utility Extract ~100 lines of duplicated theme variables, base styles, and global utilities into packages/ui/base.css, imported by both editor and review-editor CSS files. Move formatTimestamp() from AnnotationPanel and ReviewPanel into packages/ui/utils/format.ts. Co-Authored-By: Claude Opus 4.6 --- packages/editor/index.css | 126 +---------------- .../review-editor/components/ReviewPanel.tsx | 16 +-- packages/review-editor/index.css | 124 +---------------- packages/ui/base.css | 129 ++++++++++++++++++ packages/ui/components/AnnotationPanel.tsx | 16 +-- packages/ui/utils/format.ts | 16 +++ 6 files changed, 149 insertions(+), 278 deletions(-) create mode 100644 packages/ui/base.css create mode 100644 packages/ui/utils/format.ts diff --git a/packages/editor/index.css b/packages/editor/index.css index 9c9919a..fb3bd7b 100644 --- a/packages/editor/index.css +++ b/packages/editor/index.css @@ -1,111 +1,11 @@ @import "tailwindcss"; +@import "../ui/base.css"; /* Tell Tailwind where to scan for classes */ @source "../ui/components/**/*.tsx"; @source "../ui/hooks/**/*.ts"; @source "./*.tsx"; -:root { - --background: oklch(0.15 0.02 260); - --foreground: oklch(0.90 0.01 260); - --card: oklch(0.22 0.02 260); - --card-foreground: oklch(0.90 0.01 260); - --popover: oklch(0.28 0.025 260); - --popover-foreground: oklch(0.90 0.01 260); - --primary: oklch(0.75 0.18 280); - --primary-foreground: oklch(0.15 0.02 260); - --secondary: oklch(0.65 0.15 180); - --secondary-foreground: oklch(0.15 0.02 260); - --muted: oklch(0.26 0.02 260); - --muted-foreground: oklch(0.72 0.02 260); - --accent: oklch(0.70 0.20 60); - --accent-foreground: oklch(0.15 0.02 260); - --destructive: oklch(0.65 0.20 25); - --destructive-foreground: oklch(0.98 0 0); - --border: oklch(0.35 0.02 260); - --input: oklch(0.26 0.02 260); - --ring: oklch(0.75 0.18 280); - --success: oklch(0.72 0.17 150); - --success-foreground: oklch(0.15 0.02 260); - --warning: oklch(0.75 0.15 85); - --warning-foreground: oklch(0.20 0.02 260); - - --font-sans: 'Inter', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', monospace; - --radius: 0.75rem; -} - -.light { - --background: oklch(0.97 0.005 260); - --foreground: oklch(0.18 0.02 260); - --card: oklch(1 0 0); - --card-foreground: oklch(0.18 0.02 260); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.18 0.02 260); - --primary: oklch(0.50 0.25 280); - --primary-foreground: oklch(1 0 0); - --secondary: oklch(0.50 0.18 180); - --secondary-foreground: oklch(1 0 0); - --muted: oklch(0.92 0.01 260); - --muted-foreground: oklch(0.40 0.02 260); - --accent: oklch(0.60 0.22 50); - --accent-foreground: oklch(0.18 0.02 260); - --destructive: oklch(0.50 0.25 25); - --destructive-foreground: oklch(1 0 0); - --border: oklch(0.88 0.01 260); - --input: oklch(0.92 0.01 260); - --ring: oklch(0.50 0.25 280); - --success: oklch(0.45 0.20 150); - --success-foreground: oklch(1 0 0); - --warning: oklch(0.55 0.18 85); - --warning-foreground: oklch(0.18 0.02 260); -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-success: var(--success); - --color-success-foreground: var(--success-foreground); - --color-warning: var(--warning); - --color-warning-foreground: var(--warning-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --font-sans: var(--font-sans); - --font-mono: var(--font-mono); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); -} - -@layer base { - * { - border-color: var(--border); - } -} - -body { - font-family: var(--font-sans); - background: var(--background); - color: var(--foreground); - font-feature-settings: "ss01", "ss02", "cv01"; -} - /* Subtle grid background */ .bg-grid { background-image: @@ -129,12 +29,6 @@ body { box-shadow: 0 0 10px oklch(0.75 0.18 280 / 0.1); } -/* Custom scrollbar */ -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } - /* Code blocks */ pre code.hljs { display: block; @@ -189,24 +83,6 @@ pre code.hljs { color: oklch(0.45 0.20 280) !important; } -/* Selection */ -::selection { - background: oklch(0.75 0.18 280 / 0.3); -} - -/* Smooth transitions */ -* { - transition-property: color, background-color, border-color, box-shadow, opacity, transform; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 150ms; -} - -/* Focus states */ -:focus-visible { - outline: 2px solid var(--ring); - outline-offset: 2px; -} - /* Annotation highlights */ .annotation-highlight { border-radius: 2px; diff --git a/packages/review-editor/components/ReviewPanel.tsx b/packages/review-editor/components/ReviewPanel.tsx index a7128f5..16d37bd 100644 --- a/packages/review-editor/components/ReviewPanel.tsx +++ b/packages/review-editor/components/ReviewPanel.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { CodeAnnotation } from '@plannotator/ui/types'; import { isCurrentUser } from '@plannotator/ui/utils/identity'; +import { formatTimestamp } from '@plannotator/ui/utils/format'; import { HighlightedCode } from './HighlightedCode'; import { detectLanguage } from '../utils/detectLanguage'; import { renderInlineMarkdown } from '../utils/renderInlineMarkdown'; @@ -49,21 +50,6 @@ const SuggestionPreview: React.FC<{ code: string; originalCode?: string; languag ); }; -function formatTimestamp(ts: number): string { - const now = Date.now(); - const diff = now - ts; - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (seconds < 60) return 'now'; - if (minutes < 60) return `${minutes}m`; - if (hours < 24) return `${hours}h`; - if (days < 7) return `${days}d`; - - return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); -} export const ReviewPanel: React.FC = ({ isOpen, diff --git a/packages/review-editor/index.css b/packages/review-editor/index.css index 8c52841..10567b7 100644 --- a/packages/review-editor/index.css +++ b/packages/review-editor/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "../ui/base.css"; /* Tell Tailwind where to scan for classes */ @source "../ui/components/**/*.tsx"; @@ -6,129 +7,6 @@ @source "./*.tsx"; @source "./components/**/*.tsx"; -:root { - --background: oklch(0.15 0.02 260); - --foreground: oklch(0.90 0.01 260); - --card: oklch(0.22 0.02 260); - --card-foreground: oklch(0.90 0.01 260); - --popover: oklch(0.28 0.025 260); - --popover-foreground: oklch(0.90 0.01 260); - --primary: oklch(0.75 0.18 280); - --primary-foreground: oklch(0.15 0.02 260); - --secondary: oklch(0.65 0.15 180); - --secondary-foreground: oklch(0.15 0.02 260); - --muted: oklch(0.26 0.02 260); - --muted-foreground: oklch(0.72 0.02 260); - --accent: oklch(0.70 0.20 60); - --accent-foreground: oklch(0.15 0.02 260); - --destructive: oklch(0.65 0.20 25); - --destructive-foreground: oklch(0.98 0 0); - --border: oklch(0.35 0.02 260); - --input: oklch(0.26 0.02 260); - --ring: oklch(0.75 0.18 280); - --success: oklch(0.72 0.17 150); - --success-foreground: oklch(0.15 0.02 260); - --warning: oklch(0.75 0.15 85); - --warning-foreground: oklch(0.20 0.02 260); - - --font-sans: 'Inter', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', monospace; - --radius: 0.75rem; -} - -.light { - --background: oklch(0.97 0.005 260); - --foreground: oklch(0.18 0.02 260); - --card: oklch(1 0 0); - --card-foreground: oklch(0.18 0.02 260); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.18 0.02 260); - --primary: oklch(0.50 0.25 280); - --primary-foreground: oklch(1 0 0); - --secondary: oklch(0.50 0.18 180); - --secondary-foreground: oklch(1 0 0); - --muted: oklch(0.92 0.01 260); - --muted-foreground: oklch(0.40 0.02 260); - --accent: oklch(0.60 0.22 50); - --accent-foreground: oklch(0.18 0.02 260); - --destructive: oklch(0.50 0.25 25); - --destructive-foreground: oklch(1 0 0); - --border: oklch(0.88 0.01 260); - --input: oklch(0.92 0.01 260); - --ring: oklch(0.50 0.25 280); - --success: oklch(0.45 0.20 150); - --success-foreground: oklch(1 0 0); - --warning: oklch(0.55 0.18 85); - --warning-foreground: oklch(0.18 0.02 260); -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-success: var(--success); - --color-success-foreground: var(--success-foreground); - --color-warning: var(--warning); - --color-warning-foreground: var(--warning-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --font-sans: var(--font-sans); - --font-mono: var(--font-mono); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); -} - -* { - border-color: var(--border); -} - -body { - font-family: var(--font-sans); - background: var(--background); - color: var(--foreground); - font-feature-settings: "ss01", "ss02", "cv01"; -} - -/* Custom scrollbar */ -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } - -/* Selection */ -::selection { - background: oklch(0.75 0.18 280 / 0.3); -} - -/* Smooth transitions */ -* { - transition-property: color, background-color, border-color, box-shadow, opacity, transform; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 150ms; -} - -/* Focus states */ -:focus-visible { - outline: 2px solid var(--ring); - outline-offset: 2px; -} - /* ===================================================== Code Review Specific Styles ===================================================== */ diff --git a/packages/ui/base.css b/packages/ui/base.css new file mode 100644 index 0000000..d6ae694 --- /dev/null +++ b/packages/ui/base.css @@ -0,0 +1,129 @@ +/** + * Shared theme and base styles for plan editor and review editor. + * Each app imports this then adds its own app-specific styles. + */ + +:root { + --background: oklch(0.15 0.02 260); + --foreground: oklch(0.90 0.01 260); + --card: oklch(0.22 0.02 260); + --card-foreground: oklch(0.90 0.01 260); + --popover: oklch(0.28 0.025 260); + --popover-foreground: oklch(0.90 0.01 260); + --primary: oklch(0.75 0.18 280); + --primary-foreground: oklch(0.15 0.02 260); + --secondary: oklch(0.65 0.15 180); + --secondary-foreground: oklch(0.15 0.02 260); + --muted: oklch(0.26 0.02 260); + --muted-foreground: oklch(0.72 0.02 260); + --accent: oklch(0.70 0.20 60); + --accent-foreground: oklch(0.15 0.02 260); + --destructive: oklch(0.65 0.20 25); + --destructive-foreground: oklch(0.98 0 0); + --border: oklch(0.35 0.02 260); + --input: oklch(0.26 0.02 260); + --ring: oklch(0.75 0.18 280); + --success: oklch(0.72 0.17 150); + --success-foreground: oklch(0.15 0.02 260); + --warning: oklch(0.75 0.15 85); + --warning-foreground: oklch(0.20 0.02 260); + + --font-sans: 'Inter', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --radius: 0.75rem; +} + +.light { + --background: oklch(0.97 0.005 260); + --foreground: oklch(0.18 0.02 260); + --card: oklch(1 0 0); + --card-foreground: oklch(0.18 0.02 260); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.18 0.02 260); + --primary: oklch(0.50 0.25 280); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.50 0.18 180); + --secondary-foreground: oklch(1 0 0); + --muted: oklch(0.92 0.01 260); + --muted-foreground: oklch(0.40 0.02 260); + --accent: oklch(0.60 0.22 50); + --accent-foreground: oklch(0.18 0.02 260); + --destructive: oklch(0.50 0.25 25); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.88 0.01 260); + --input: oklch(0.92 0.01 260); + --ring: oklch(0.50 0.25 280); + --success: oklch(0.45 0.20 150); + --success-foreground: oklch(1 0 0); + --warning: oklch(0.55 0.18 85); + --warning-foreground: oklch(0.18 0.02 260); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +@layer base { + * { + border-color: var(--border); + } +} + +body { + font-family: var(--font-sans); + background: var(--background); + color: var(--foreground); + font-feature-settings: "ss01", "ss02", "cv01"; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } + +/* Selection */ +::selection { + background: oklch(0.75 0.18 280 / 0.3); +} + +/* Smooth transitions */ +* { + transition-property: color, background-color, border-color, box-shadow, opacity, transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* Focus states */ +:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} diff --git a/packages/ui/components/AnnotationPanel.tsx b/packages/ui/components/AnnotationPanel.tsx index ec8d7f7..be14c1e 100644 --- a/packages/ui/components/AnnotationPanel.tsx +++ b/packages/ui/components/AnnotationPanel.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect } from 'react'; import { Annotation, AnnotationType, Block } from '../types'; import { isCurrentUser } from '../utils/identity'; +import { formatTimestamp } from '../utils/format'; import { ImageThumbnail } from './ImageThumbnail'; interface PanelProps { @@ -114,21 +115,6 @@ export const AnnotationPanel: React.FC = ({ ); }; -function formatTimestamp(ts: number): string { - const now = Date.now(); - const diff = now - ts; - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (seconds < 60) return 'now'; - if (minutes < 60) return `${minutes}m`; - if (hours < 24) return `${hours}h`; - if (days < 7) return `${days}d`; - - return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); -} const AnnotationCard: React.FC<{ annotation: Annotation; diff --git a/packages/ui/utils/format.ts b/packages/ui/utils/format.ts new file mode 100644 index 0000000..340e574 --- /dev/null +++ b/packages/ui/utils/format.ts @@ -0,0 +1,16 @@ +/** Format a timestamp as a relative time string (e.g., "now", "5m", "2h", "3d") */ +export function formatTimestamp(ts: number): string { + const now = Date.now(); + const diff = now - ts; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return 'now'; + if (minutes < 60) return `${minutes}m`; + if (hours < 24) return `${hours}h`; + if (days < 7) return `${days}d`; + + return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} From 97040737c6d47b32893fc361b6b6dfc228aa4599 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 3 Mar 2026 16:32:04 -0800 Subject: [PATCH 3/9] chore: remove dead code (knip findings) - Delete unimplemented S3 paste store stub - Un-export internal helpers in ImageAnnotator/utils.ts - Un-export extractDoneSteps in pi-extension/utils.ts Co-Authored-By: Claude Opus 4.6 --- apps/paste-service/stores/s3.ts | 17 ----------------- apps/pi-extension/utils.ts | 2 +- packages/ui/components/ImageAnnotator/utils.ts | 6 +++--- 3 files changed, 4 insertions(+), 21 deletions(-) delete mode 100644 apps/paste-service/stores/s3.ts 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/pi-extension/utils.ts b/apps/pi-extension/utils.ts index eb570b4..4501ee9 100644 --- a/apps/pi-extension/utils.ts +++ b/apps/pi-extension/utils.ts @@ -84,7 +84,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/packages/ui/components/ImageAnnotator/utils.ts b/packages/ui/components/ImageAnnotator/utils.ts index e227fce..6909a23 100644 --- a/packages/ui/components/ImageAnnotator/utils.ts +++ b/packages/ui/components/ImageAnnotator/utils.ts @@ -30,7 +30,7 @@ function getSvgPathFromStroke(stroke: number[][]): string { /** * Render a freehand pen stroke using perfect-freehand */ -export function renderPenStroke( +function renderPenStroke( ctx: CanvasRenderingContext2D, points: Point[], color: string, @@ -50,7 +50,7 @@ export function renderPenStroke( /** * Render an arrow from start to end point */ -export function renderArrow( +function renderArrow( ctx: CanvasRenderingContext2D, start: Point, end: Point, @@ -95,7 +95,7 @@ export function renderArrow( /** * Render a circle from edge to edge (first click is one edge, drag to opposite edge) */ -export function renderCircle( +function renderCircle( ctx: CanvasRenderingContext2D, start: Point, end: Point, From 84ef52be7aa8f613c90500a742a8b9620838bc84 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 3 Mar 2026 16:59:36 -0800 Subject: [PATCH 4/9] chore: add biome linter/formatter with CI workflow - Install @biomejs/biome with recommended rules - Configure formatting to match existing conventions (2 spaces, single quotes, trailing commas) - Enable Tailwind CSS directive parsing - Downgrade noisy rules to warnings (a11y, exhaustive-deps, explicit-any, etc.) - Add lint/format/check scripts to root package.json - Add CI workflow that runs biome ci on PRs to main Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 13 ++++++++++ biome.json | 53 ++++++++++++++++++++++++++++++++++++++++ bun.lock | 26 +++++++++++++++++--- package.json | 10 +++++++- 4 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 biome.json 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/biome.json b/biome.json new file mode 100644 index 0000000..4c9d70d --- /dev/null +++ b/biome.json @@ -0,0 +1,53 @@ +{ + "$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": "off", + "useIterableCallbackReturn": "warn" + }, + "correctness": { + "useExhaustiveDependencies": "warn", + "noUnusedFunctionParameters": "warn", + "noInvalidUseBeforeDeclaration": "off" + }, + "security": { + "noDangerouslySetInnerHtml": "warn" + }, + "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" } } From 4e6d55c85f3184ecaad4a90a06aa0d5340da37f2 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 3 Mar 2026 17:00:10 -0800 Subject: [PATCH 5/9] style: apply biome formatting and fix lint issues Formatting: normalize all source files to biome conventions (2-space indent, single quotes, trailing commas, 100 char width, sorted imports). Lint fixes: - Remove unused import (corsHeaders in paste-service handler) - Remove unused variables (sharedGlobalAttachments, validateAgent, availableAgents, DEFAULT_SETTINGS) - Remove unused catch parameters - Add node: protocol to Node.js builtin imports - Replace @ts-ignore with @ts-expect-error - Suppress intentional document.cookie usage in storage utility Co-Authored-By: Claude Opus 4.6 --- apps/hook/dev-mock-api.ts | 48 +- apps/hook/index.tsx | 8 +- apps/hook/server/index.ts | 104 +- apps/hook/tsconfig.json | 12 +- apps/hook/vite.config.ts | 8 +- apps/opencode-plugin/index.ts | 158 +- apps/paste-service/core/cors.ts | 16 +- apps/paste-service/core/handler.ts | 57 +- apps/paste-service/stores/fs.ts | 12 +- apps/paste-service/stores/kv.ts | 2 +- apps/paste-service/targets/bun.ts | 21 +- apps/paste-service/targets/cloudflare.ts | 8 +- apps/pi-extension/index.ts | 300 +-- apps/pi-extension/package.json | 12 +- apps/pi-extension/server.ts | 233 +-- apps/pi-extension/utils.ts | 89 +- apps/portal/index.tsx | 8 +- apps/portal/tsconfig.json | 12 +- apps/portal/vite.config.ts | 8 +- apps/review/index.tsx | 6 +- apps/review/server/index.ts | 47 +- apps/review/tsconfig.json | 10 +- apps/review/vite.config.ts | 13 +- packages/editor/App.tsx | 701 ++++--- packages/editor/index.css | 36 +- packages/review-editor/App.tsx | 544 ++++-- .../components/AnnotationToolbar.tsx | 52 +- .../review-editor/components/DiffViewer.tsx | 140 +- .../review-editor/components/FileHeader.tsx | 49 +- .../review-editor/components/FileTree.tsx | 160 +- .../components/HighlightedCode.tsx | 8 +- .../components/InlineAnnotation.tsx | 38 +- .../review-editor/components/ReviewPanel.tsx | 316 +-- .../components/SuggestionBlock.tsx | 14 +- .../components/SuggestionDiff.tsx | 7 +- .../components/SuggestionModal.tsx | 79 +- .../hooks/useAnnotationToolbar.ts | 121 +- packages/review-editor/hooks/useTabIndent.ts | 25 +- packages/review-editor/index.css | 52 +- .../review-editor/utils/detectLanguage.ts | 39 +- packages/review-editor/utils/patchParser.ts | 10 +- .../utils/renderInlineMarkdown.tsx | 10 +- packages/server/annotate.ts | 53 +- packages/server/browser.ts | 23 +- packages/server/git.ts | 58 +- packages/server/ide.ts | 14 +- packages/server/image.ts | 34 +- packages/server/index.ts | 167 +- packages/server/integrations.ts | 105 +- packages/server/project.test.ts | 98 +- packages/server/project.ts | 30 +- packages/server/reference-handlers.ts | 84 +- packages/server/remote.ts | 8 +- packages/server/repo.ts | 8 +- packages/server/review.ts | 64 +- packages/server/share-url.ts | 15 +- packages/server/shared-handlers.ts | 31 +- packages/server/storage.ts | 88 +- packages/shared/compress.ts | 11 +- packages/shared/crypto.test.ts | 83 +- packages/shared/crypto.ts | 36 +- packages/ui/base.css | 48 +- packages/ui/components/AnnotationPanel.tsx | 256 ++- packages/ui/components/AnnotationSidebar.tsx | 67 +- packages/ui/components/AnnotationToolbar.tsx | 110 +- packages/ui/components/AttachmentsButton.tsx | 319 ++-- packages/ui/components/CompletionOverlay.tsx | 24 +- packages/ui/components/ConfirmDialog.tsx | 44 +- packages/ui/components/ExportModal.tsx | 132 +- .../ui/components/ImageAnnotator/Canvas.tsx | 5 +- .../ui/components/ImageAnnotator/Toolbar.tsx | 2 +- .../ui/components/ImageAnnotator/index.tsx | 42 +- .../ui/components/ImageAnnotator/utils.ts | 38 +- packages/ui/components/ImageThumbnail.tsx | 15 +- packages/ui/components/ImportModal.tsx | 44 +- packages/ui/components/Landing.tsx | 83 +- packages/ui/components/MermaidBlock.tsx | 92 +- packages/ui/components/ModeSwitcher.tsx | 191 +- packages/ui/components/ModeToggle.tsx | 7 +- .../ui/components/PermissionModeSetup.tsx | 37 +- packages/ui/components/ResizeHandle.tsx | 2 +- packages/ui/components/Settings.tsx | 1133 ++++++----- packages/ui/components/TableOfContents.tsx | 37 +- packages/ui/components/TaterSpritePullup.tsx | 2 +- packages/ui/components/TaterSpriteRunning.tsx | 2 +- packages/ui/components/TaterSpriteSitting.tsx | 2 +- packages/ui/components/ThemeProvider.tsx | 8 +- packages/ui/components/UIFeaturesSetup.tsx | 38 +- packages/ui/components/UpdateBanner.tsx | 55 +- packages/ui/components/Viewer.test.tsx | 219 +-- packages/ui/components/Viewer.tsx | 1699 +++++++++-------- .../plan-diff/PlanCleanDiffView.tsx | 101 +- .../ui/components/plan-diff/PlanDiffBadge.tsx | 16 +- .../plan-diff/PlanDiffMarketing.tsx | 71 +- .../plan-diff/PlanDiffModeSwitcher.tsx | 31 +- .../components/plan-diff/PlanDiffViewer.tsx | 44 +- .../components/plan-diff/PlanRawDiffView.tsx | 51 +- .../ui/components/plan-diff/VSCodeIcon.tsx | 47 +- .../components/sidebar/SidebarContainer.tsx | 44 +- .../ui/components/sidebar/SidebarTabs.tsx | 24 +- .../ui/components/sidebar/VaultBrowser.tsx | 84 +- .../ui/components/sidebar/VersionBrowser.tsx | 42 +- packages/ui/components/types.d.ts | 2 +- packages/ui/hooks/useActiveSection.ts | 35 +- packages/ui/hooks/useAgents.ts | 15 +- packages/ui/hooks/useAutoClose.ts | 7 +- .../ui/hooks/useDismissOnOutsideAndEscape.ts | 14 +- packages/ui/hooks/useLinkedDoc.ts | 17 +- packages/ui/hooks/usePlanDiff.ts | 61 +- packages/ui/hooks/useResizablePanel.ts | 13 +- packages/ui/hooks/useSharing.ts | 237 ++- packages/ui/hooks/useSidebar.ts | 8 +- packages/ui/hooks/useUpdateCheck.ts | 6 +- packages/ui/hooks/useVaultBrowser.ts | 12 +- packages/ui/types.ts | 2 +- packages/ui/utils/annotationHelpers.ts | 38 +- packages/ui/utils/editorMode.ts | 2 +- packages/ui/utils/identity.ts | 2 +- packages/ui/utils/obsidian.ts | 43 +- packages/ui/utils/parser.ts | 72 +- packages/ui/utils/permissionMode.ts | 6 +- packages/ui/utils/planDiffEngine.ts | 18 +- packages/ui/utils/planSave.ts | 5 - packages/ui/utils/sharing.ts | 71 +- packages/ui/utils/storage.ts | 8 +- tests/manual/test-review-server.ts | 15 +- tests/manual/test-server.ts | 10 +- 127 files changed, 6169 insertions(+), 4461 deletions(-) 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/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..6027c1f 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, _update, 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 4501ee9..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 }); 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/packages/editor/App.tsx b/packages/editor/App.tsx index 158ffaf..f9a0f92 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,14 +426,14 @@ const App: React.FC = () => { } else { sidebar.close(); } - }, [uiPrefs.tocEnabled]); + }, [uiPrefs.tocEnabled, sidebar.close, sidebar.open]); // Clear diff view when switching away from versions tab useEffect(() => { if (sidebar.activeTab === 'toc' && isPlanDiffActive) { setIsPlanDiffActive(false); } - }, [sidebar.activeTab]); + }, [sidebar.activeTab, isPlanDiffActive]); // Clear diff view on Escape key useEffect(() => { @@ -432,53 +452,79 @@ 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(); - const showVaultTab = useMemo(() => isVaultBrowserEnabled(), [uiPrefs]); + const showVaultTab = useMemo(() => isVaultBrowserEnabled(), []); const vaultPath = useMemo(() => { if (!showVaultTab) return ''; const settings = getObsidianSettings(); return getEffectiveVaultPath(settings); - }, [showVaultTab, uiPrefs]); + }, [showVaultTab]); // 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 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]); + }, [ + sidebar.activeTab, + showVaultTab, + vaultPath, + vaultBrowser.fetchTree, + vaultBrowser.isLoading, + vaultBrowser.tree.length, + ]); 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 +537,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 +550,6 @@ const App: React.FC = () => { isGeneratingShortUrl, shortUrlError, pendingSharedAnnotations, - sharedGlobalAttachments, clearPendingSharedAnnotations, generateShortUrl, importFromShareUrl, @@ -522,11 +567,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 +604,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 +684,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 +714,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 +741,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 +773,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 +785,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 +815,7 @@ const App: React.FC = () => { enabled: planSaveSettings.enabled, ...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }), }, - }) + }), }); setSubmitted('denied'); } catch { @@ -783,8 +852,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 +906,59 @@ 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, + handleAnnotateFeedback, + handleApprove, + handleDeny, ]); 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 +966,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 +1028,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' }); } @@ -954,8 +1049,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 +1084,18 @@ 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, + handleDownloadAnnotations, + handleQuickSaveToNotes, ]); // Close export dropdown on click outside @@ -1030,13 +1143,15 @@ const App: React.FC = () => { v{typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'} {origin && ( -