diff --git a/docs/assets/v11-review-ui-after.png b/docs/assets/v11-review-ui-after.png new file mode 100644 index 00000000..adc33c12 Binary files /dev/null and b/docs/assets/v11-review-ui-after.png differ diff --git a/docs/assets/v11-review-ui-before.png b/docs/assets/v11-review-ui-before.png new file mode 100644 index 00000000..0593fa24 Binary files /dev/null and b/docs/assets/v11-review-ui-before.png differ diff --git a/docs/assets/v11-review-ui-interaction.mp4 b/docs/assets/v11-review-ui-interaction.mp4 new file mode 100644 index 00000000..3d5b422d Binary files /dev/null and b/docs/assets/v11-review-ui-interaction.mp4 differ diff --git a/package.json b/package.json index 723881ae..301edf4c 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/user-config.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/user-config.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/server.ts b/src/server.ts index 39839958..dd0a15e8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -61,7 +61,6 @@ import { logToolCall, resultOutputSchema, textBlock, - textSummary, workspaceAppDescriptorMeta, } from "./tool-surfaces/shared.js"; import { @@ -619,11 +618,6 @@ export function createMcpServer( } workspaces.markReadPathLoaded(workspace, readPath); - const summary = { - ...textSummary(response.content), - offset: input.offset ?? 1, - limited: input.limit !== undefined, - }; logToolCall(config, { tool: toolNames.read, workspaceId, @@ -634,15 +628,6 @@ export function createMcpServer( return { ...response, - _meta: { - tool: toolNames.read, - card: { - workspaceId, - path: input.path, - summary, - payload: { content: response.content }, - }, - }, structuredContent: { result: contentText(response.content), }, diff --git a/src/tool-surfaces/claude.ts b/src/tool-surfaces/claude.ts index 19a47049..e1b98858 100644 --- a/src/tool-surfaces/claude.ts +++ b/src/tool-surfaces/claude.ts @@ -1,4 +1,3 @@ -import { existsSync } from "node:fs"; import * as z from "zod/v4"; import { editFileTool, @@ -15,15 +14,12 @@ import { type ToolRegistrationContext, } from "./types.js"; import { - contentLineCount, contentText, countDiffStats, logFailedToolResponse, logToolCall, - newFilePatch, resultOutputSchema, textBlock, - textSummary, } from "./shared.js"; const CLAUDE_INSTRUCTIONS = `Use ${toolNames.read} for direct file reads, ${toolNames.shell} with command-line tools such as rg, find, ls, and tree for search and directory inspection, ${toolNames.edit} for targeted modifications, and ${toolNames.write} only for new files or complete rewrites. Use ${toolNames.shell} for tests, builds, git inspection, package scripts, and other commands, but do not create or modify files through shell commands. Shell commands run with the local user's authority and are not sandboxed; workspace validation only selects their initial working directory. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; @@ -63,8 +59,7 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { async ({ workspaceId, ...input }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); - const absolutePath = workspaces.resolvePath(workspace, input.path); - const overwritesExistingFile = existsSync(absolutePath); + workspaces.resolvePath(workspace, input.path); const response = await writeFileTool(input, { cwd: workspace.root, root: workspace.root, @@ -84,17 +79,6 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { return response; } - // An aggregate review can show the real replacement diff. A new-file - // patch would misrepresent an overwrite as additions with no removals. - const patch = overwritesExistingFile - ? undefined - : newFilePatch(input.path, input.content); - const stats = countDiffStats(patch); - const summary = { - ...stats, - lines: contentLineCount(input.content), - characters: input.content.length, - }; logToolCall(config, { tool: toolNames.write, workspaceId, @@ -105,18 +89,6 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { return { ...response, - _meta: { - tool: toolNames.write, - card: { - workspaceId, - path: input.path, - summary, - payload: { - content: response.content, - patch, - }, - }, - }, structuredContent: { result: contentText(response.content), }, @@ -178,10 +150,6 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { const stats = countDiffStats( response.details?.patch ?? response.details?.diff, ); - const summary = { - ...stats, - editCount: input.edits.length, - }; const editResultText = `Edited ${input.path} (+${stats.additions} -${stats.removals}).`; const editContent = [textBlock(editResultText)]; logToolCall(config, { @@ -194,18 +162,6 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { return { content: editContent, - _meta: { - tool: toolNames.edit, - card: { - workspaceId, - path: input.path, - summary, - payload: { - diff: response.details?.diff, - patch: response.details?.patch, - }, - }, - }, structuredContent: { status: "applied", result: contentText(editContent), @@ -274,11 +230,6 @@ function registerShellTool(context: ToolRegistrationContext): void { return response; } - const summary = { - command: input.command, - workingDirectory: workingDirectory ?? ".", - ...textSummary(response.content), - }; logToolCall(config, { tool: toolNames.shell, workspaceId, @@ -291,15 +242,6 @@ function registerShellTool(context: ToolRegistrationContext): void { return { ...response, - _meta: { - tool: toolNames.shell, - card: { - workspaceId, - path: workingDirectory, - summary, - payload: { content: response.content }, - }, - }, structuredContent: { result: contentText(response.content), }, diff --git a/src/tool-surfaces/codex.ts b/src/tool-surfaces/codex.ts index c9a196f3..526e175b 100644 --- a/src/tool-surfaces/codex.ts +++ b/src/tool-surfaces/codex.ts @@ -13,7 +13,6 @@ import { resultOutputSchema, runLoggedToolOperation, textBlock, - textSummary, } from "./shared.js"; type CodexRegistration = (context: ToolRegistrationContext) => void; @@ -57,27 +56,11 @@ function processOutputSchema(): z.ZodRawShape { }); } -function processToolResponse( - tool: "exec_command" | "write_stdin", - workspaceId: string, - snapshot: ProcessSnapshot, - summary: Record, -) { +function processToolResponse(snapshot: ProcessSnapshot) { const result = processResult(snapshot); const content = [textBlock(result)]; - const outputSummary = textSummary( - snapshot.output ? [textBlock(snapshot.output)] : [], - ); return { content, - _meta: { - tool, - card: { - workspaceId, - summary: { ...summary, ...outputSummary }, - payload: { content }, - }, - }, structuredContent: { result, sessionId: snapshot.sessionId, @@ -134,27 +117,9 @@ function registerApplyPatchTool(context: ToolRegistrationContext): void { const paths = applied.files.map((file) => file.path).join(", "); const result = `Applied patch to ${applied.files.length} file(s): ${paths}`; const content = [textBlock(result)]; - const displayPath = - applied.files.length === 1 - ? applied.files[0]?.path - : `${applied.files.length} files`; return { content, - _meta: { - tool: "apply_patch", - card: { - workspaceId, - path: displayPath, - summary: { - files: applied.files.length, - additions: applied.additions, - removals: applied.removals, - }, - files: applied.files, - payload: { patch: applied.patch }, - }, - }, structuredContent: { result, additions: applied.additions, @@ -265,13 +230,7 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { }, ); - return processToolResponse("exec_command", workspaceId, snapshot, { - command: cmd, - workingDirectory: workingDirectory ?? ".", - running: snapshot.running, - exitCode: snapshot.exitCode, - wallTimeMs: snapshot.wallTimeMs, - }); + return processToolResponse(snapshot); }, ); @@ -356,13 +315,7 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { }, ); - return processToolResponse("write_stdin", workspaceId, snapshot, { - sessionId, - charactersWritten: chars?.length ?? 0, - running: snapshot.running, - exitCode: snapshot.exitCode, - wallTimeMs: snapshot.wallTimeMs, - }); + return processToolResponse(snapshot); }, ); } diff --git a/src/tool-surfaces/shared.ts b/src/tool-surfaces/shared.ts index 45c3ba83..abfc0ba3 100644 --- a/src/tool-surfaces/shared.ts +++ b/src/tool-surfaces/shared.ts @@ -104,24 +104,6 @@ export function textBlock(text: string): ToolContent { return { type: "text", text }; } -export function textSummary(content: ToolContent[]): { - lines: number; - characters: number; -} { - const text = contentText(content); - return { - lines: contentLineCount(text), - characters: text.length, - }; -} - -export function contentLineCount(content: string): number { - if (content.length === 0) return 0; - return content.endsWith("\n") - ? content.slice(0, -1).split("\n").length - : content.split("\n").length; -} - export function countDiffStats(diff: string | undefined): DiffStats { if (!diff) return { additions: 0, removals: 0 }; @@ -135,27 +117,3 @@ export function countDiffStats(diff: string | undefined): DiffStats { return { additions, removals }; } - -export function newFilePatch(path: string, content: string): string { - const lines = - content.length === 0 - ? [] - : content.endsWith("\n") - ? content.slice(0, -1).split("\n") - : content.split("\n"); - const hunkLength = lines.length; - const hunkRange = hunkLength === 0 ? "+0,0" : `+1,${hunkLength}`; - const body = lines.map((line) => `+${line}`).join("\n"); - - return [ - `diff --git a/${path} b/${path}`, - "new file mode 100644", - "index 0000000..0000000", - "--- /dev/null", - `+++ b/${path}`, - `@@ -0,0 ${hunkRange} @@`, - body, - ] - .filter((line) => line.length > 0) - .join("\n"); -} diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index 3c05d144..2ae9f55e 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -1,113 +1,35 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - isEditTool, isExpandableCard, isInitiallyExpandedCard, - isPatchTool, - isShellTool, isToolName, } from "./card-types.js"; -test("the supported coding tools are recognized as card tools", () => { - for (const tool of ["apply_patch", "exec_command", "write_stdin"]) { - assert.equal(isToolName(tool), true, `${tool} should be a recognized card tool`); - } +test("only UI-backed tools are recognized as card tools", () => { + assert.equal(isToolName("open_workspace"), true); + assert.equal(isToolName("show_changes"), true); + assert.equal(isToolName("read"), false); }); -test("tool classification distinguishes patch, edit, and shell operations", () => { - assert.equal(isPatchTool("apply_patch"), true); - assert.equal(isEditTool("apply_patch"), false); - assert.equal(isShellTool("apply_patch"), false); - assert.equal(isShellTool("exec_command"), true); - assert.equal(isShellTool("write_stdin"), true); - assert.equal(isEditTool("exec_command"), false); +test("aggregate review opens when a patch is available", () => { + const card = { + tool: "show_changes" as const, + files: [{ path: "src/a.ts", type: "change" as const }], + payload: { patch: "diff --git a/src/a.ts b/src/a.ts" }, + }; + assert.equal(isExpandableCard(card), true); + assert.equal(isInitiallyExpandedCard(card), true); }); -test("a patch card expands only when it contains patch content", () => { - assert.equal( - isExpandableCard({ tool: "apply_patch", payload: { patch: "diff --git a/a b/a" } }), - true, - ); - assert.equal(isExpandableCard({ tool: "apply_patch" }), false); -}); - -test("a single-file patch opens immediately", () => { - assert.equal( - isInitiallyExpandedCard({ - tool: "apply_patch", - files: [{ path: "src/a.ts", operation: "update" }], - payload: { patch: "diff --git a/src/a.ts b/src/a.ts" }, - }), - true, - ); -}); - -test("a multi-file patch stays collapsed", () => { - assert.equal( - isInitiallyExpandedCard({ - tool: "apply_patch", - files: [ - { path: "src/a.ts", operation: "update" }, - { path: "src/b.ts", operation: "add" }, - ], - payload: { patch: "diff --git a/src/a.ts b/src/a.ts" }, - }), - false, - ); -}); - -test("show changes still opens immediately", () => { - assert.equal( - isInitiallyExpandedCard({ - tool: "show_changes", - files: [{ path: "src/a.ts", type: "change" }], - payload: { patch: "diff --git a/src/a.ts b/src/a.ts" }, - }), - true, - ); -}); - -test("a workspace card expands when it contains provider metadata", () => { - assert.equal( - isExpandableCard({ - tool: "open_workspace", - agentProviders: [{ id: "codex" }], - }), - true, - ); -}); - -test("a workspace card with details opens immediately", () => { - assert.equal( - isInitiallyExpandedCard({ - tool: "open_workspace", - skills: [{ name: "research" }], - }), - true, - ); -}); - -test("a workspace card expands when it contains agent metadata", () => { - assert.equal( - isExpandableCard({ - tool: "open_workspace", - agents: [{ name: "reviewer", provider: "codex" }], - }), - true, - ); -}); - -test("a workspace card expands when it contains available instruction files", () => { - assert.equal( - isExpandableCard({ - tool: "open_workspace", - availableAgentsFiles: [{ path: "nested/AGENTS.md" }], - }), - true, - ); -}); - -test("an empty workspace card stays collapsed", () => { +test("workspace details open only when there is useful context", () => { assert.equal(isExpandableCard({ tool: "open_workspace" }), false); + assert.equal(isInitiallyExpandedCard({ + tool: "open_workspace", + skills: [{ name: "research" }], + }), true); + assert.equal(isExpandableCard({ + tool: "open_workspace", + review: { available: false, reason: "Not a Git repository." }, + }), true); }); diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index cac8b8fd..5ab0a532 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -1,22 +1,8 @@ import type { App } from "@modelcontextprotocol/ext-apps"; -export type ToolName = - | "open_workspace" - | "show_changes" - | "apply_patch" - | "exec_command" - | "write_stdin" - | "read" - | "write" - | "edit" - | "grep" - | "glob" - | "ls" - | "bash"; - +export type ToolName = "open_workspace" | "show_changes"; export type HostContext = NonNullable>; -export type PatchOperation = "add" | "update" | "delete" | "move"; export type ReviewFileType = | "change" | "rename-pure" @@ -41,17 +27,18 @@ export interface ToolResultCard { detached?: boolean; managed?: boolean; }; - status?: string; + review?: + | { available: true } + | { available: false; reason: string }; summary?: Record; files?: Array<{ path?: string; previousPath?: string; - operation?: PatchOperation; type?: ReviewFileType; additions?: number; removals?: number; }>; - payload?: ToolPayload; + payload?: { patch?: string }; agentsFiles?: Array<{ path?: string; content?: string; @@ -80,80 +67,14 @@ export interface ToolResultCard { instruction?: string; } -export interface ToolContent { - type: "text" | "image"; - text?: string; - data?: string; - mimeType?: string; -} - -export interface ToolPayload { - content?: ToolContent[]; - diff?: string; - patch?: string; -} - export function isToolName(value: unknown): value is ToolName { - return ( - value === "open_workspace" || - value === "show_changes" || - value === "apply_patch" || - value === "exec_command" || - value === "write_stdin" || - value === "read" || - value === "write" || - value === "edit" || - value === "grep" || - value === "glob" || - value === "ls" || - value === "bash" - ); -} - -export function isReadTool(tool: ToolName): boolean { - return tool === "read"; -} - -export function isWriteTool(tool: ToolName): boolean { - return tool === "write"; -} - -export function isEditTool(tool: ToolName): boolean { - return tool === "edit"; -} - -export function isPatchTool(tool: ToolName): boolean { - return tool === "apply_patch"; -} - -export function isSearchTool(tool: ToolName): boolean { - return tool === "grep" || tool === "glob"; -} - -export function isShellTool(tool: ToolName): boolean { - return tool === "bash" || tool === "exec_command" || tool === "write_stdin"; -} - -export function isReviewTool(tool: ToolName): boolean { - return tool === "show_changes"; + return value === "open_workspace" || value === "show_changes"; } export function isToolResultCard(value: unknown): value is Omit { return Boolean(value && typeof value === "object"); } -export function payloadText(payload: ToolPayload | undefined): string { - return ( - payload?.content - ?.map((item) => { - if (item.type === "text") return item.text ?? ""; - return `[${item.mimeType ?? "image"} image payload]`; - }) - .filter(Boolean) - .join("\n\n") ?? "" - ); -} - export function summaryNumber( summary: Record | undefined, key: string, @@ -163,33 +84,26 @@ export function summaryNumber( } export function isExpandableCard(card: ToolResultCard): boolean { - if (card.tool === "open_workspace") { - return ( - Number(card.summary?.agentsFiles ?? 0) > 0 || - Number(card.summary?.skills ?? 0) > 0 || - Number(card.summary?.agentProviders ?? 0) > 0 || - Number(card.summary?.agents ?? 0) > 0 || - Boolean(card.agentsFiles?.length) || - Boolean(card.availableAgentsFiles?.length) || - Boolean(card.skills?.length) || - Boolean(card.agentProviders?.length) || - Boolean(card.agents?.length) || - Boolean(card.worktree) || - Boolean(card.instruction) - ); + if (card.tool === "show_changes") { + return Boolean(card.files?.length || card.payload?.patch); } - if (isReviewTool(card.tool)) return Boolean(card.files?.length || card.payload?.patch); - if (isPatchTool(card.tool)) return Boolean(card.payload?.patch); - - return Boolean(card.payload); + return ( + Number(card.summary?.agentsFiles ?? 0) > 0 || + Number(card.summary?.skills ?? 0) > 0 || + Number(card.summary?.agentProviders ?? 0) > 0 || + Number(card.summary?.agents ?? 0) > 0 || + Boolean(card.agentsFiles?.length) || + Boolean(card.availableAgentsFiles?.length) || + Boolean(card.skills?.length) || + Boolean(card.agentProviders?.length) || + Boolean(card.agents?.length) || + Boolean(card.worktree) || + Boolean(card.instruction) || + card.review?.available === false + ); } export function isInitiallyExpandedCard(card: ToolResultCard): boolean { - if (card.tool === "open_workspace") return isExpandableCard(card); - if (isReviewTool(card.tool)) return isExpandableCard(card); - if (isPatchTool(card.tool)) { - return card.files?.length === 1 && isExpandableCard(card); - } - return false; + return isExpandableCard(card); } diff --git a/src/ui/heavy-payload.tsx b/src/ui/heavy-payload.tsx deleted file mode 100644 index a61e6dac..00000000 --- a/src/ui/heavy-payload.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { FileStream, getFiletypeFromFileName } from "@pierre/diffs"; -import type { FileStreamOptions } from "@pierre/diffs"; -import { PatchDiff } from "@pierre/diffs/react"; -import { - isEditTool, - isReadTool, - isWriteTool, - payloadText, - summaryNumber, - type HostContext, - type ToolResultCard, -} from "./card-types.js"; -import { pierrePrettyScrollbarCss } from "./scrollbar.js"; - -type ThemeType = "light" | "dark"; - -interface PayloadRendererOptions { - card: ToolResultCard; - hostContext?: HostContext; - errorMessage?: string | null; -} - -interface MountedPayload { - update(options: PayloadRendererOptions): void; - unmount(): void; -} - -export function mountHeavyPayload( - container: HTMLElement, - options: PayloadRendererOptions, -): MountedPayload { - const root = createRoot(container); - root.render(); - - return { - update(nextOptions) { - root.render(); - }, - unmount() { - root.unmount(); - }, - }; -} - -export type { MountedPayload, PayloadRendererOptions }; - -function HeavyPayload({ - card, - hostContext, - errorMessage = null, -}: PayloadRendererOptions) { - const themeType: ThemeType = hostContext?.theme === "light" ? "light" : "dark"; - - if (errorMessage) { - return ; - } - - if (isEditTool(card.tool) || isWriteTool(card.tool)) { - const patch = card.payload?.patch || card.payload?.diff; - if (!patch) return ; - - return ; - } - - const text = payloadText(card.payload); - if (!text) return ; - - if (isReadTool(card.tool)) { - return ( - - ); - } - - return
{text}
; -} - -function FilePayload({ - path, - text, - startLine, - themeType, -}: { - path: string; - text: string; - startLine: number; - themeType: ThemeType; -}) { - const wrapperRef = useRef(null); - const fileOptions: FileStreamOptions = useMemo( - () => ({ - theme: { - light: "pierre-light", - dark: "pierre-dark", - }, - themeType, - overflow: "scroll", - unsafeCSS: pierrePrettyScrollbarCss, - }), - [themeType], - ); - - useEffect(() => { - const wrapper = wrapperRef.current; - if (!wrapper) return; - - const fileStream = new FileStream({ - ...fileOptions, - lang: getFiletypeFromFileName(path), - startingLineIndex: startLine, - }); - const source = new ReadableStream({ - start(controller) { - controller.enqueue(text); - controller.close(); - }, - }); - let disposed = false; - - void fileStream.setup(source, wrapper).then(() => { - if (!disposed) return; - fileStream.cleanUp(); - wrapper.replaceChildren(); - }); - - return () => { - disposed = true; - fileStream.cleanUp(); - wrapper.replaceChildren(); - }; - }, [fileOptions, path, startLine, text]); - - return
; -} - -function DiffPayload({ - patch, - themeType, -}: { - patch: string; - themeType: ThemeType; -}) { - return ( - - ); -} - -function StatusLine({ - message, - tone = "muted", -}: { - message: string; - tone?: "muted" | "error"; -}) { - return
{message}
; -} diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 022105d0..8bada413 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -6,20 +6,11 @@ import { Cpu, FileDiff, FileCheck2, - FileMinus, - FilePenLine, - FilePlus, FileText, - Files, FolderGit2, FolderOpen, - FolderTree, GitBranch, GitCommitHorizontal, - LoaderCircle, - Search, - SquareTerminal, - Terminal, createElement, type IconNode, } from "lucide"; @@ -28,26 +19,16 @@ export const toolIcons = { agents: Bot, base: GitCommitHorizontal, chevronDown: ChevronDown, - deleteFile: FileMinus, diff: FileDiff, - editFile: FilePenLine, - files: Files, folderOpen: FolderOpen, - folderTree: FolderTree, gitBranch: GitBranch, instructions: FileText, instructionAvailable: FileText, instructionLoaded: FileCheck2, - loading: LoaderCircle, providers: Cpu, - readFile: FileText, - search: Search, skills: Blocks, sourceCheckout: FolderGit2, - terminal: Terminal, - terminalSquare: SquareTerminal, warning: CircleAlert, - writeFile: FilePlus, } as const satisfies Record; export type ToolIcon = IconNode; diff --git a/src/ui/patch-display.test.ts b/src/ui/patch-display.test.ts index 612809ff..7b582256 100644 --- a/src/ui/patch-display.test.ts +++ b/src/ui/patch-display.test.ts @@ -1,205 +1,42 @@ import assert from "node:assert/strict"; +import test from "node:test"; import { getFileChangePathDisplay, getPatchDisplayParts, getRenderedFileChangeKind, - getRenderedFileChangePathDisplay, } from "./patch-display.js"; -assert.deepEqual(getPatchDisplayParts({}), { - title: "Applied patch", - tone: "edit", -}); - -assert.deepEqual( - getPatchDisplayParts({ files: [{ path: "created.ts", operation: "add" }] }), - { - title: "Added 1 file", - iconKind: "added", - tone: "write", - }, -); - -assert.deepEqual( - getPatchDisplayParts({ +test("review titles describe a uniform or mixed file set", () => { + assert.equal(getPatchDisplayParts( + { files: [] }, + { emptyTitle: "Changes ready" }, + ).title, "Changes ready"); + assert.equal(getPatchDisplayParts({ + files: [{ path: "a.ts", type: "new" }], + }).title, "Added 1 file"); + assert.equal(getPatchDisplayParts({ files: [ - { path: "a.ts", operation: "add" }, - { path: "b.ts", operation: "add" }, - ], - }), - { - title: "Added 2 files", - iconKind: "added", - tone: "write", - }, -); - -assert.deepEqual( - getFileChangePathDisplay({ - path: "src/new-name.ts", - previousPath: "src/old-name.ts", - }), - { - current: "new-name.ts", - previous: "old-name.ts", - title: "src/old-name.ts → src/new-name.ts", - }, -); - -assert.deepEqual( - getFileChangePathDisplay({ - path: "packages/new/file.ts", - previousPath: "src/old/file.ts", - }), - { - current: "packages/new/file.ts", - previous: "src/old/file.ts", - title: "src/old/file.ts → packages/new/file.ts", - }, -); - -assert.deepEqual( - getRenderedFileChangePathDisplay( - [{ path: "src/new-name.ts", previousPath: "src/old-name.ts", operation: "move" }], - { path: "src/new-name.ts" }, - 0, - ), - { - current: "new-name.ts", - previous: "old-name.ts", - title: "src/old-name.ts → src/new-name.ts", - }, -); - -assert.deepEqual( - getRenderedFileChangePathDisplay( - [ - { path: "shared.ts", previousPath: "first.ts", operation: "move" }, - { path: "shared.ts", previousPath: "second.ts", operation: "move" }, - ], - { path: "shared.ts" }, - 1, - ), - { - current: "shared.ts", - previous: "second.ts", - title: "second.ts → shared.ts", - }, -); - -assert.equal( - getRenderedFileChangeKind( - [ - { path: "same.tmp", operation: "add" }, - { path: "same.tmp", operation: "delete" }, - ], - { path: "same.tmp", type: "new" }, - 0, - ), - "added", -); - -assert.equal( - getRenderedFileChangeKind( - [ - { path: "same.tmp", operation: "add" }, - { path: "same.tmp", operation: "delete" }, + { path: "a.ts", type: "new" }, + { path: "b.ts", type: "change" }, ], - { path: "same.tmp", type: "deleted" }, - 1, - ), - "deleted", -); + }).title, "Changed 2 files"); +}); -assert.equal( - getRenderedFileChangeKind( - [{ path: "report.md", operation: "add" }], - { path: "report.md", type: "change" }, - 0, - ), - "edited", -); +test("rename paths stay compact within one directory", () => { + assert.deepEqual(getFileChangePathDisplay({ + path: "src/new.ts", + previousPath: "src/old.ts", + }), { + current: "new.ts", + previous: "old.ts", + title: "src/old.ts → src/new.ts", + }); +}); -assert.equal( - getRenderedFileChangeKind( - [{ path: "renamed.md", previousPath: "old.md", operation: "move" }], - { path: "renamed.md", type: "change" }, +test("card metadata fills gaps in parsed diff metadata", () => { + assert.equal(getRenderedFileChangeKind( + [{ path: "renamed.ts", type: "rename-pure" }], + { path: "renamed.ts" }, 0, - ), - "renamed", -); - -assert.deepEqual( - getPatchDisplayParts({ files: [{ path: "created.ts", type: "new" }] }), - { - title: "Added 1 file", - iconKind: "added", - tone: "write", - }, -); - -assert.deepEqual( - getPatchDisplayParts({ files: [{ path: "renamed.ts", type: "rename-changed" }] }), - { - title: "Renamed and edited 1 file", - iconKind: "renamed-edited", - tone: "edit", - }, -); - -assert.deepEqual( - getPatchDisplayParts({ files: [{ path: "removed.ts", type: "deleted" }] }), - { - title: "Deleted 1 file", - iconKind: "deleted", - tone: "delete", - }, -); - -assert.deepEqual( - getPatchDisplayParts({ files: [{ path: "unknown.ts" }] }), - { - title: "Changed 1 file", - tone: "edit", - }, -); - -assert.deepEqual( - getPatchDisplayParts({ - files: [ - { path: "created.ts", operation: "add" }, - { path: "edited.ts", operation: "update" }, - ], - }), - { - title: "Changed 2 files", - tone: "edit", - }, -); - -assert.deepEqual( - getPatchDisplayParts({ - files: [ - { path: "same.ts", operation: "add" }, - { path: "same.ts", operation: "update" }, - ], - }), - { - title: "Changed 1 file", - tone: "edit", - }, -); - -assert.deepEqual( - getPatchDisplayParts({ - files: [ - { path: "edited.ts", operation: "update" }, - { path: "moved.ts", previousPath: "old.ts", operation: "move" }, - { path: "removed.ts", operation: "delete" }, - ], - }), - { - title: "Changed 3 files", - tone: "edit", - }, -); + ), "renamed"); +}); diff --git a/src/ui/patch-display.ts b/src/ui/patch-display.ts index ec1f7ad2..83b990e9 100644 --- a/src/ui/patch-display.ts +++ b/src/ui/patch-display.ts @@ -8,13 +8,7 @@ export type FileChangeKind = | "renamed-edited" | "unknown"; -type ToolResultFile = NonNullable[number]; - -export interface PatchDisplayParts { - title: string; - iconKind?: FileChangeKind; - tone: "edit" | "write" | "delete"; -} +type ReviewFile = NonNullable[number]; export interface FileChangePathDisplay { current: string; @@ -33,37 +27,22 @@ const fileChangeLabels: Record, string> = { export function getPatchDisplayParts( card: Pick, options: { emptyTitle?: string } = {}, -): PatchDisplayParts { +): { title: string } { const files = card.files ?? []; const fileCount = countChangedFiles(files); - - if (fileCount === 0) { - return { title: options.emptyTitle ?? "Applied patch", tone: "edit" }; - } + if (fileCount === 0) return { title: options.emptyTitle ?? "Changes ready" }; const kinds = new Set(files.map(getFileChangeKind)); - const singleKind = kinds.size === 1 ? [...kinds][0] : undefined; - const display: PatchDisplayParts = { - title: changeTitle(singleKind, fileCount), - tone: changeTone(singleKind), + const kind = kinds.size === 1 ? [...kinds][0] : undefined; + const noun = fileCount === 1 ? "file" : "files"; + return { + title: kind && kind !== "unknown" + ? `${fileChangeLabels[kind]} ${fileCount} ${noun}` + : `Changed ${fileCount} ${noun}`, }; - - if (singleKind && singleKind !== "unknown") display.iconKind = singleKind; - return display; } -export function getFileChangeKind(file: ToolResultFile): FileChangeKind { - switch (file.operation) { - case "add": - return "added"; - case "update": - return "edited"; - case "delete": - return "deleted"; - case "move": - return "renamed"; - } - +export function getFileChangeKind(file: ReviewFile): FileChangeKind { switch (file.type) { case "new": return "added"; @@ -82,51 +61,23 @@ export function getFileChangeKind(file: ToolResultFile): FileChangeKind { export function getRenderedFileChangeKind( files: NonNullable, - parsedFile: Pick, + parsedFile: Pick, index: number, ): FileChangeKind { const parsedKind = getFileChangeKind(parsedFile); - - // The diff parser is authoritative for additions, deletions, and native Git - // rename metadata. This also keeps repeated operations on the same path from - // reusing the first matching card entry. - if (parsedKind !== "edited" && parsedKind !== "unknown") return parsedKind; - - // apply_patch emits one card file per generated diff in the same order. Its - // move patch currently lacks Git rename metadata, so preserve the explicit - // move operation when the destination lines up with the parsed diff. - const indexedFile = files[index]; - if ( - indexedFile?.operation === "move" && - (!parsedFile.path || indexedFile.path === parsedFile.path) - ) { - return "renamed"; - } - - const movedFile = files.find((file) => ( - file.operation === "move" && - file.path === parsedFile.path && - (!parsedFile.previousPath || file.previousPath === parsedFile.previousPath) - )); - if (movedFile) return "renamed"; - - // A parsed content change is more accurate than an "add" directive that - // overwrote an existing file. - if (parsedKind === "edited") return "edited"; - - return indexedFile ? getFileChangeKind(indexedFile) : "unknown"; + return parsedKind === "unknown" + ? getFileChangeKind(files[index] ?? {}) + : parsedKind; } export function getFileChangePathDisplay( - file: Pick, + file: Pick, ): FileChangePathDisplay | undefined { const current = file.path ?? file.previousPath; if (!current) return undefined; const previous = file.previousPath; - if (!previous || previous === current) { - return { current, title: current }; - } + if (!previous || previous === current) return { current, title: current }; const sameDirectory = pathDirectory(previous) === pathDirectory(current); return { @@ -138,16 +89,13 @@ export function getFileChangePathDisplay( export function getRenderedFileChangePathDisplay( files: NonNullable, - parsedFile: Pick, + parsedFile: Pick, index: number, ): FileChangePathDisplay | undefined { const indexedFile = files[index]; const matchedFile = indexedFile?.path === parsedFile.path ? indexedFile - : files.find((file) => ( - file.path === parsedFile.path && - (!parsedFile.previousPath || !file.previousPath || file.previousPath === parsedFile.previousPath) - )); + : files.find((file) => file.path === parsedFile.path); const cardFile = matchedFile ?? indexedFile; return getFileChangePathDisplay({ @@ -163,37 +111,14 @@ export function fileChangeKindLabel(kind: FileChangeKind): string { function countChangedFiles(files: NonNullable): number { const paths = new Set(); let unnamedFiles = 0; - for (const file of files) { const path = file.path ?? file.previousPath; - if (path) { - paths.add(path); - } else { - unnamedFiles += 1; - } + if (path) paths.add(path); + else unnamedFiles += 1; } - return paths.size + unnamedFiles; } -function changeTitle(kind: FileChangeKind | undefined, fileCount: number): string { - if (kind && kind !== "unknown") { - return `${fileChangeLabels[kind]} ${fileCount} ${fileNoun(fileCount)}`; - } - - return `Changed ${fileCount} ${fileNoun(fileCount)}`; -} - -function changeTone(kind: FileChangeKind | undefined): PatchDisplayParts["tone"] { - if (kind === "added") return "write"; - if (kind === "deleted") return "delete"; - return "edit"; -} - -function fileNoun(fileCount: number): "file" | "files" { - return fileCount === 1 ? "file" : "files"; -} - function pathDirectory(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separatorIndex === -1 ? "" : path.slice(0, separatorIndex); diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts deleted file mode 100644 index 71d3504d..00000000 --- a/src/ui/tool-display.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import assert from "node:assert/strict"; -import type { ToolResultCard } from "./card-types.js"; -import { toolIcons } from "./icons.js"; -import { getToolDisplay, getToolHeaderSummary } from "./tool-display.js"; - -const displayCases: Array<[ToolResultCard, { title: string; tone: string }]> = [ - [{ tool: "open_workspace", root: "/tmp/project" }, { title: "Opened workspace", tone: "workspace" }], - [{ tool: "open_workspace", root: "/tmp/project", workspaceReused: true }, { title: "Reused workspace", tone: "workspace" }], - [{ tool: "open_workspace", root: "/tmp/project", mode: "worktree" }, { title: "Opened workspace", tone: "workspace" }], - [{ tool: "open_workspace", root: "/tmp/project", mode: "worktree", workspaceReused: true }, { title: "Reused workspace", tone: "workspace" }], - [{ tool: "read", path: "src/read.ts" }, { title: "Read file", tone: "read" }], - [{ tool: "write", path: "src/write.ts" }, { title: "Wrote file", tone: "write" }], - [{ tool: "edit", path: "src/edit.ts" }, { title: "Edited file", tone: "edit" }], - [{ - tool: "apply_patch", - files: [{ path: "src/new.ts", operation: "add" }], - }, { title: "Added 1 file", tone: "write" }], - [{ - tool: "grep", - summary: { pattern: "needle", scope: "src" }, - }, { title: "Searched files", tone: "search" }], - [{ tool: "ls", path: "src" }, { title: "Listed directory", tone: "directory" }], - [{ tool: "bash", summary: { command: "npm test", exitCode: 0 } }, { title: "Ran command", tone: "shell" }], -]; - -for (const [card, expected] of displayCases) { - assert.deepEqual(pickDisplay(getToolDisplay(card)), expected); -} - -assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); -assert.equal( - getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).icon, - toolIcons.folderOpen, -); -assert.equal( - getToolDisplay({ tool: "open_workspace", root: "/tmp/project", mode: "worktree" }).icon, - toolIcons.gitBranch, -); -assert.equal( - getToolDisplay({ tool: "grep", summary: { pattern: "needle", scope: "src" } }).label, - "needle in src", -); - -assert.equal( - getToolDisplay({ - tool: "apply_patch", - files: [{ - path: "src/new-name.ts", - previousPath: "src/old-name.ts", - operation: "move", - }], - }).label, - "src/old-name.ts → src/new-name.ts", -); - -assert.deepEqual( - pickDisplay(getToolDisplay({ - tool: "show_changes", - files: [ - { path: "src/a.ts", type: "change" }, - { path: "src/b.ts", type: "change" }, - ], - })), - { title: "Edited 2 files", tone: "review" }, -); - -assert.deepEqual( - pickDisplay(getToolDisplay({ - tool: "show_changes", - files: [ - { path: "src/a.ts", type: "new" }, - { path: "src/b.ts", type: "change" }, - ], - })), - { title: "Changed 2 files", tone: "review" }, -); - -assert.deepEqual( - pickDisplay(getToolDisplay({ - tool: "show_changes", - files: [{ path: "src/old.ts", type: "deleted" }], - })), - { title: "Deleted 1 file", tone: "review" }, -); - -assert.equal( - getToolDisplay({ tool: "show_changes", payload: { patch: "diff --git a/a b/a" } }).title, - "Changes ready", -); - -assert.equal(getToolDisplay({ tool: "show_changes" }).title, "No changes"); - -assert.equal( - getToolDisplay({ tool: "exec_command", summary: { running: true, command: "npm test" } }).title, - "Command running", -); -assert.equal( - getToolDisplay({ tool: "exec_command", summary: { running: false, exitCode: 1 } }).title, - "Command failed", -); -assert.equal( - getToolDisplay({ tool: "write_stdin", summary: { running: false, exitCode: 0 } }).title, - "Process finished", -); -assert.equal( - getToolDisplay({ tool: "exec_command", summary: { running: true } }).state, - "running", -); -assert.equal( - getToolDisplay({ tool: "exec_command", summary: { running: false, exitCode: 0 } }).state, - "success", -); -assert.equal( - getToolDisplay({ tool: "exec_command", summary: { running: false, exitCode: 1 } }).state, - "error", -); - -assert.deepEqual( - pickDisplay(getToolDisplay({ tool: "glob", summary: { lines: 1, pattern: "**/*.ts" } })), - { title: "Found files", tone: "search" }, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "glob", summary: { lines: 1 } }), - { kind: "empty" }, -); - -assert.equal( - getToolDisplay({ - tool: "apply_patch", - files: [{ path: "src/removed.ts", operation: "delete" }], - }).icon, - toolIcons.deleteFile, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "show_changes", summary: { additions: 14, removals: 1 } }), - { kind: "diff", additions: 14, removals: 1 }, -); - -assert.deepEqual( - getToolHeaderSummary({ - tool: "open_workspace", - summary: { mode: "worktree", agentsFiles: 1, skills: 4 }, - }), - { kind: "text", text: "1 instruction · 4 skills" }, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "exec_command", summary: { lines: 3, wallTimeMs: 1_500 } }), - { kind: "text", text: "3 lines · 1.5s" }, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "grep", summary: { lines: 2 } }), - { kind: "text", text: "2 lines" }, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "read", summary: { lines: 1 } }), - { kind: "text", text: "1 line" }, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "ls", summary: { lines: 0 } }), - { kind: "text", text: "0 lines" }, -); - -assert.deepEqual( - getToolHeaderSummary({ tool: "open_workspace" }), - { kind: "empty" }, -); - -function pickDisplay(display: ReturnType) { - return { - title: display.title, - tone: display.tone, - }; -} diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts deleted file mode 100644 index be64e0b0..00000000 --- a/src/ui/tool-display.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { - isEditTool, - isPatchTool, - isReviewTool, - isShellTool, - isWriteTool, - summaryNumber, - type ToolResultCard, -} from "./card-types.js"; -import { toolIcons, type ToolIcon } from "./icons.js"; -import { - getFileChangePathDisplay, - getPatchDisplayParts, -} from "./patch-display.js"; - -export interface ToolDisplay { - icon: ToolIcon; - title: string; - label?: string; - tone: string; - state?: "running" | "success" | "error"; -} - -export type ToolHeaderSummary = - | { kind: "diff"; additions: number; removals: number } - | { kind: "text"; text: string } - | { kind: "empty" }; - -export function getToolDisplay(card: ToolResultCard): ToolDisplay { - switch (card.tool) { - case "open_workspace": - return { - icon: card.mode === "worktree" ? toolIcons.gitBranch : toolIcons.folderOpen, - title: workspaceTitle(card), - label: card.root ?? card.path, - tone: "workspace", - }; - case "read": - return { - icon: toolIcons.readFile, - title: "Read file", - label: card.path, - tone: "read", - }; - case "write": - return { - icon: toolIcons.writeFile, - title: "Wrote file", - label: card.path, - tone: "write", - }; - case "edit": - return { - icon: toolIcons.editFile, - title: "Edited file", - label: card.path, - tone: "edit", - }; - case "apply_patch": { - const display = getPatchDisplayParts(card); - return { - icon: patchIcon(display.iconKind), - title: display.title, - label: singleFilePath(card), - tone: display.tone, - }; - } - case "grep": - return { - icon: toolIcons.search, - title: "Searched files", - label: searchLabel(card), - tone: "search", - }; - case "glob": { - return { - icon: toolIcons.files, - title: "Found files", - label: searchLabel(card), - tone: "search", - }; - } - case "ls": - return { - icon: toolIcons.folderTree, - title: "Listed directory", - label: card.path, - tone: "directory", - }; - case "bash": - case "exec_command": - return { - icon: toolIcons.terminalSquare, - title: processTitle(card, "command"), - label: processLabel(card), - tone: "shell", - state: processState(card), - }; - case "write_stdin": - return { - icon: toolIcons.terminal, - title: processTitle(card, "process"), - label: processLabel(card), - tone: "shell", - state: processState(card), - }; - case "show_changes": { - const display = getPatchDisplayParts(card, { emptyTitle: "Changes ready" }); - const fileCount = card.files?.length ?? 0; - return { - icon: toolIcons.diff, - title: fileCount > 0 || card.payload?.patch - ? display.title - : "No changes", - label: singleFilePath(card), - tone: "review", - }; - } - } -} - -export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { - const summary = card.summary ?? {}; - - if (isReviewTool(card.tool) || isPatchTool(card.tool) || isEditTool(card.tool) || isWriteTool(card.tool)) { - return { - kind: "diff", - additions: summaryNumber(summary, "additions") ?? 0, - removals: summaryNumber(summary, "removals") ?? 0, - }; - } - - if (card.tool === "open_workspace") { - const parts = [ - countLabel(summaryNumber(summary, "agentsFiles"), "instruction"), - countLabel(summaryNumber(summary, "skills"), "skill"), - ].filter((part): part is string => Boolean(part)); - return parts.length > 0 ? { kind: "text", text: parts.join(" · ") } : { kind: "empty" }; - } - - if (isShellTool(card.tool)) { - const parts = [ - countLabel(summaryNumber(summary, "lines"), "line"), - durationLabel(summaryNumber(summary, "wallTimeMs")), - ].filter((part): part is string => Boolean(part)); - return parts.length > 0 ? { kind: "text", text: parts.join(" · ") } : { kind: "empty" }; - } - - if (card.tool === "grep" || card.tool === "read" || card.tool === "ls") { - const lines = countLabel(summaryNumber(summary, "lines"), "line"); - return lines ? { kind: "text", text: lines } : { kind: "empty" }; - } - - return { kind: "empty" }; -} - -function patchIcon(kind: ReturnType["iconKind"]): ToolIcon { - if (kind === "added") return toolIcons.writeFile; - if (kind === "deleted") return toolIcons.deleteFile; - if (kind === "renamed" || kind === "renamed-edited") return toolIcons.files; - return toolIcons.editFile; -} - -function workspaceTitle(card: ToolResultCard): string { - return `${card.workspaceReused ? "Reused" : "Opened"} workspace`; -} - -function singleFilePath(card: ToolResultCard): string | undefined { - if (card.files?.length === 1) { - return getFileChangePathDisplay(card.files[0])?.title ?? card.path; - } - return undefined; -} - -function searchLabel(card: ToolResultCard): string | undefined { - const pattern = card.summary?.pattern; - const scope = card.summary?.scope; - if (typeof pattern !== "string") return card.path; - return typeof scope === "string" && scope !== "." ? `${pattern} in ${scope}` : pattern; -} - -function processTitle(card: ToolResultCard, subject: "command" | "process"): string { - if (card.summary?.running === true) { - return subject === "command" ? "Command running" : "Process running"; - } - - const exitCode = summaryNumber(card.summary, "exitCode"); - if (exitCode !== undefined && exitCode !== 0) { - return subject === "command" ? "Command failed" : "Process failed"; - } - - return subject === "command" ? "Ran command" : "Process finished"; -} - -function processState(card: ToolResultCard): ToolDisplay["state"] { - if (card.summary?.running === true) return "running"; - const exitCode = summaryNumber(card.summary, "exitCode"); - if (exitCode !== undefined && exitCode !== 0) return "error"; - return exitCode === 0 ? "success" : undefined; -} - -function processLabel(card: ToolResultCard): string | undefined { - const command = card.summary?.command; - if (typeof command === "string") return command; - const sessionId = card.summary?.sessionId; - if (typeof sessionId === "number" || typeof sessionId === "string") { - return `Session ${String(sessionId)}`; - } - return card.path; -} - -function countLabel(count: number | undefined, noun: string): string | undefined { - if (count === undefined) return undefined; - return `${count} ${noun}${count === 1 ? "" : "s"}`; -} - -function durationLabel(durationMs: number | undefined): string | undefined { - if (durationMs === undefined) return undefined; - if (durationMs < 1_000) return `${Math.round(durationMs)}ms`; - return `${(durationMs / 1_000).toFixed(durationMs < 10_000 ? 1 : 0)}s`; -} diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index bee72228..906cbeb6 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -42,45 +42,14 @@ body { color: var(--color-text-primary, #f5f5f6); } -.tool-card.workspace, -.tool-card.directory { +.tool-card.workspace { --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 34%, #3b82f6 66%); } -.tool-card.read, -.tool-card.search { - --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 32%, #06b6d4 68%); -} - -.tool-card.write { - --tool-accent: var(--color-success-text, #6fda83); -} - -.tool-card.edit, .tool-card.review { --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 28%, #d99742 72%); } -.tool-card.delete { - --tool-accent: var(--color-danger-text, #ee7676); -} - -.tool-card.shell { - --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 42%, #64748b 58%); -} - -.tool-card.state-success { - --tool-accent: var(--color-success-text, #6fda83); -} - -.tool-card.state-error { - --tool-accent: var(--color-danger-text, #ee7676); -} - -.tool-card.state-running { - --tool-accent: color-mix(in srgb, var(--color-text-primary, #f5f5f6) 30%, #38bdf8 70%); -} - @supports selector(::-webkit-scrollbar) { .pretty-scrollbar::-webkit-scrollbar { width: 12px; @@ -248,29 +217,6 @@ body { transform: rotate(180deg); } -.chevron.loading { - transform: none; -} - -.chevron.loading .icon-svg { - animation: payload-spinner 700ms linear infinite; - fill: none; - stroke-linecap: round; - stroke-dasharray: 38 14; -} - -@keyframes payload-spinner { - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: reduce) { - .chevron.loading .icon-svg { - animation: none; - } -} - .tool-body { border-top: 1px solid var(--tool-card-divider); background: var(--tool-card-body-bg); @@ -836,8 +782,7 @@ body { color: var(--color-danger-text, #ee7676); } -.pierre-diff, -.pierre-file { +.pierre-diff { --diffs-bg: var(--tool-payload-bg, var(--color-background-primary, #101114)); --diffs-light-bg: var(--color-background-primary, #ffffff); --diffs-dark-bg: var(--tool-payload-bg, var(--color-background-primary, #101114)); @@ -852,24 +797,6 @@ body { border-bottom-left-radius: 8px; } -.text-payload { - max-height: 420px; - margin: 0; - overflow: auto; - padding: 10px 12px; - color: var(--color-text-secondary, #c7c7ce); - font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: var(--font-text-sm-size, 12px); - line-height: 1.55; - white-space: pre-wrap; - overflow-wrap: break-word; -} - -.text-payload.bash { - color: var(--color-text-primary, #f5f5f6); - background: var(--color-background-primary, #101114); -} - @media (max-width: 520px) { .tool-header { grid-template-columns: 36px minmax(0, 1fr) auto 18px; diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index cd833c2c..c3c6f36e 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -6,28 +6,29 @@ import { } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { - isEditTool, isExpandableCard, isInitiallyExpandedCard, - isPatchTool, - isReadTool, - isReviewTool, isToolName, isToolResultCard, - isWriteTool, - payloadText, + summaryNumber, type HostContext, type ToolName, type ToolResultCard, } from "./card-types.js"; import { getProviderLogo, renderIcon, toolIcons, type ToolIcon } from "./icons.js"; import { - getToolDisplay, - getToolHeaderSummary, - type ToolDisplay, -} from "./tool-display.js"; + getFileChangePathDisplay, + getPatchDisplayParts, +} from "./patch-display.js"; import "./workspace-app.css"; +interface CardDisplay { + icon: ToolIcon; + title: string; + label?: string; + tone: "workspace" | "review"; +} + interface MountedPayload { update(options: { card: ToolResultCard; @@ -162,8 +163,8 @@ function render(): void { return; } - const display = getToolDisplay(card); - if (isReviewTool(card.tool)) { + const display = cardDisplay(card); + if (card.tool === "show_changes") { renderReviewCard(card, display); return; } @@ -241,72 +242,25 @@ async function renderPayloadIfNeeded(): Promise { return; } - if (shouldUseHeavyPayload(card)) { - if (currentPayload) { - currentPayload.update({ card, hostContext, errorMessage }); - return; - } - - setPayloadLoading(target, true); - - try { - const { mountHeavyPayload } = await import("./heavy-payload.js"); - if (target !== currentPayloadContainer || !expanded || !card) return; - - setPayloadLoading(target, false); - currentPayload = mountHeavyPayload(target, { - card, - hostContext, - errorMessage, - }); - } catch (loadError) { - if (target !== currentPayloadContainer || !expanded) return; - - setPayloadLoading(target, false); - renderStatus( - target, - loadError instanceof Error ? loadError.message : "Unable to load details.", - "error", - ); - } - return; - } - - if (isReviewTool(card.tool) || isPatchTool(card.tool)) { - const visibleFileCount = isReviewTool(card.tool) && !reviewFilesExpanded - ? Math.max(3, (card.files ?? []).slice(0, 3).length) - : undefined; - - if (currentPayload) { - currentPayload.update({ card, hostContext, errorMessage, visibleFileCount }); - return; - } - - renderStatus(target, isReviewTool(card.tool) ? "Loading review..." : "Loading diff..."); - - const { mountReviewPayload } = await import("./review-payload.js"); - if (target !== currentPayloadContainer || !card) return; + const visibleFileCount = !reviewFilesExpanded + ? Math.max(3, (card.files ?? []).slice(0, 3).length) + : undefined; - currentPayload = mountReviewPayload(target, { - card, - hostContext, - errorMessage, - visibleFileCount, - }); + if (currentPayload) { + currentPayload.update({ card, hostContext, errorMessage, visibleFileCount }); return; } - const text = payloadText(card.payload); - if (!text) { - renderStatus(target, "No details available."); - return; - } - - renderPrePayload(target, text, card.tool); -} + renderStatus(target, "Loading review..."); + const { mountReviewPayload } = await import("./review-payload.js"); + if (target !== currentPayloadContainer || !card) return; -function shouldUseHeavyPayload(card: ToolResultCard): boolean { - return isReadTool(card.tool) || isEditTool(card.tool) || isWriteTool(card.tool); + currentPayload = mountReviewPayload(target, { + card, + hostContext, + errorMessage, + visibleFileCount, + }); } function unmountPayload(): void { @@ -329,40 +283,36 @@ function renderStatus( container.replaceChildren(element("div", { className: `status ${tone}`, text: message })); } -function renderPrePayload( - container: HTMLElement, - text: string, - tool: string, -): void { - unmountCurrentPayload(); - container.replaceChildren(element("pre", { - className: `text-payload pretty-scrollbar ${tool}`, - text, - })); -} - function renderHeaderSummary(card: ToolResultCard): HTMLElement { - const summary = getToolHeaderSummary(card); - - if (summary.kind === "diff") { + if (card.tool === "show_changes") { const stats = element("span", { className: "stats" }); stats.setAttribute("aria-label", "Diff statistics"); stats.append( - element("span", { className: "add", text: `+${String(summary.additions)}` }), - element("span", { className: "remove", text: `-${String(summary.removals)}` }), + element("span", { + className: "add", + text: `+${String(summaryNumber(card.summary, "additions") ?? 0)}`, + }), + element("span", { + className: "remove", + text: `-${String(summaryNumber(card.summary, "removals") ?? 0)}`, + }), ); return stats; } + const parts = [ + countLabel(summaryNumber(card.summary, "agentsFiles"), "instruction"), + countLabel(summaryNumber(card.summary, "skills"), "skill"), + ].filter((part): part is string => Boolean(part)); const meta = element("span", { - className: `header-meta ${summary.kind === "empty" ? "empty" : ""}`, - text: summary.kind === "text" ? summary.text : "", + className: `header-meta ${parts.length === 0 ? "empty" : ""}`, + text: parts.join(" · "), }); - if (summary.kind === "empty") meta.setAttribute("aria-hidden", "true"); + if (parts.length === 0) meta.setAttribute("aria-hidden", "true"); return meta; } -function renderReviewCard(card: ToolResultCard, display: ToolDisplay): void { +function renderReviewCard(card: ToolResultCard, display: CardDisplay): void { unmountPayload(); const files = card.files ?? []; @@ -445,24 +395,37 @@ function renderChevron(isExpanded: boolean, visible: boolean): HTMLElement { return chevron; } -function toolCardClassName(display: ToolDisplay): string { - return ["tool-card", display.tone, display.state ? `state-${display.state}` : undefined] - .filter(Boolean) - .join(" "); +function toolCardClassName(display: CardDisplay): string { + return `tool-card ${display.tone}`; } -function setPayloadLoading(container: HTMLElement, loading: boolean): void { - const header = container.previousElementSibling; - const chevron = header?.querySelector(".chevron"); - if (!chevron) return; +function cardDisplay(card: ToolResultCard): CardDisplay { + if (card.tool === "open_workspace") { + return { + icon: card.mode === "worktree" ? toolIcons.gitBranch : toolIcons.folderOpen, + title: `${card.workspaceReused ? "Reused" : "Opened"} workspace`, + label: card.root ?? card.path, + tone: "workspace", + }; + } + + const display = getPatchDisplayParts(card, { emptyTitle: "Changes ready" }); + return { + icon: toolIcons.diff, + title: card.files?.length || card.payload?.patch ? display.title : "No changes", + label: singleFilePath(card), + tone: "review", + }; +} - chevron.classList.toggle("loading", loading); - chevron.replaceChildren( - renderIcon(loading ? toolIcons.loading : toolIcons.chevronDown), - ); +function singleFilePath(card: ToolResultCard): string | undefined { + if (card.files?.length !== 1) return undefined; + return getFileChangePathDisplay(card.files[0])?.title ?? card.path; +} - const button = header instanceof HTMLButtonElement ? header : null; - if (button) button.setAttribute("aria-busy", String(loading)); +function countLabel(count: number | undefined, noun: string): string | undefined { + if (count === undefined) return undefined; + return `${count} ${noun}${count === 1 ? "" : "s"}`; } function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): void { @@ -510,6 +473,15 @@ function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): v ); } + if (card.review?.available === false) { + appendWorkspaceTextRow( + rows, + "Review", + card.review.reason, + toolIcons.warning, + ); + } + appendWorkspaceInstructions( rows, card.agentsFiles ?? [],