diff --git a/package-lock.json b/package-lock.json index afb1655b..0d7decc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", @@ -4799,6 +4800,12 @@ "integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==", "license": "MIT" }, + "node_modules/lucide": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.24.0.tgz", + "integrity": "sha512-oMAaeuNDc5VCnBb3IjwKYGRT56tqanUm1fyDFT5Tl8hWSZND59gztgjvXje08jKLPVAq0gHJcwZUE8GCQxzBeg==", + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index af895b37..0b8c5eb6 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.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/local-agent-runtime.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/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.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/local-agent-runtime.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/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], @@ -48,6 +48,7 @@ "diff": "^8.0.3", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "lucide": "^1.24.0", "react": "^19.2.6", "react-dom": "^19.2.6", "semver": "^7.8.4", diff --git a/src/server.ts b/src/server.ts index c72e1434..bbf5dc35 100644 --- a/src/server.ts +++ b/src/server.ts @@ -856,6 +856,7 @@ function createMcpServer( root: workspace.root, path: workspace.root, summary: { + mode: workspace.mode, agentsFiles: loadedAgentsFiles.length, availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, @@ -1206,6 +1207,7 @@ function createMcpServer( additions: applied.additions, removals: applied.removals, }, + files: applied.files, payload: { patch: applied.patch }, }, }, diff --git a/src/ui/icons.ts b/src/ui/icons.ts new file mode 100644 index 00000000..22f86002 --- /dev/null +++ b/src/ui/icons.ts @@ -0,0 +1,42 @@ +import { + ChevronDown, + FileDiff, + FileMinus, + FilePenLine, + FilePlus, + FileText, + Files, + FolderOpen, + FolderTree, + LoaderCircle, + Search, + SquareTerminal, + Terminal, + createElement, + type IconNode, +} from "lucide"; + +export const toolIcons = { + chevronDown: ChevronDown, + deleteFile: FileMinus, + diff: FileDiff, + editFile: FilePenLine, + files: Files, + folderOpen: FolderOpen, + folderTree: FolderTree, + loading: LoaderCircle, + readFile: FileText, + search: Search, + terminal: Terminal, + terminalSquare: SquareTerminal, + writeFile: FilePlus, +} as const satisfies Record; + +export type ToolIcon = IconNode; + +export function renderIcon(icon: ToolIcon, className = "icon-svg"): SVGElement { + return createElement(icon, { + class: className, + "aria-hidden": "true", + }); +} diff --git a/src/ui/patch-display.test.ts b/src/ui/patch-display.test.ts index 5583acea..ea1b268b 100644 --- a/src/ui/patch-display.test.ts +++ b/src/ui/patch-display.test.ts @@ -2,14 +2,14 @@ import assert from "node:assert/strict"; import { getPatchDisplayParts } from "./patch-display.js"; assert.deepEqual(getPatchDisplayParts({}), { - title: "Apply Patch", + title: "Applied patch", tone: "edit", }); assert.deepEqual( getPatchDisplayParts({ files: [{ path: "created.ts", operation: "add" }] }), { - title: "Write File", + title: "Added 1 file", iconOperation: "add", tone: "write", }, @@ -23,7 +23,7 @@ assert.deepEqual( ], }), { - title: "Write Files", + title: "Added 2 files", iconOperation: "add", tone: "write", }, @@ -37,7 +37,7 @@ assert.deepEqual( ], }), { - title: "Write & Edit Files", + title: "Changed 2 files", tone: "edit", }, ); @@ -50,7 +50,7 @@ assert.deepEqual( ], }), { - title: "Write & Edit File", + title: "Changed 1 file", tone: "edit", }, ); @@ -64,7 +64,7 @@ assert.deepEqual( ], }), { - title: "Edit, Move & Delete Files", + title: "Changed 3 files", tone: "edit", }, ); diff --git a/src/ui/patch-display.ts b/src/ui/patch-display.ts index 92632331..cd8f7791 100644 --- a/src/ui/patch-display.ts +++ b/src/ui/patch-display.ts @@ -7,10 +7,10 @@ export interface PatchDisplayParts { } const patchOperationLabels: Record = { - add: "Write", - update: "Edit", - delete: "Delete", - move: "Move", + add: "Added", + update: "Edited", + delete: "Deleted", + move: "Moved", }; export function getPatchDisplayParts(card: Pick): PatchDisplayParts { @@ -18,7 +18,7 @@ export function getPatchDisplayParts(card: Pick): Patch const operations = patchOperations(files); if (operations.length === 0) { - return { title: "Apply Patch", tone: "edit" }; + return { title: "Applied patch", tone: "edit" }; } const singleOperation = operations.length === 1 ? operations[0] : undefined; @@ -57,18 +57,12 @@ function countChangedFiles(files: NonNullable): number function patchTitle(operations: PatchOperation[], fileCount: number): string { if (operations.length === 1) { - return `${patchOperationLabels[operations[0]]} ${fileNoun(fileCount)}`; + return `${patchOperationLabels[operations[0]]} ${fileCount} ${fileNoun(fileCount)}`; } - return `${joinTitleParts(operations.map((operation) => patchOperationLabels[operation]))} ${fileNoun(fileCount)}`; + return `Changed ${fileCount} ${fileNoun(fileCount)}`; } -function fileNoun(fileCount: number): "File" | "Files" { - return fileCount === 1 ? "File" : "Files"; -} - -function joinTitleParts(parts: string[]): string { - if (parts.length <= 1) return parts[0] ?? ""; - if (parts.length === 2) return `${parts[0]} & ${parts[1]}`; - return `${parts.slice(0, -1).join(", ")} & ${parts.at(-1)}`; +function fileNoun(fileCount: number): "file" | "files" { + return fileCount === 1 ? "file" : "files"; } diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts new file mode 100644 index 00000000..b9977ac8 --- /dev/null +++ b/src/ui/tool-display.test.ts @@ -0,0 +1,129 @@ +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: "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: "grep", summary: { pattern: "needle", scope: "src" } }).label, + "needle in src", +); + +assert.deepEqual( + pickDisplay(getToolDisplay({ + tool: "show_changes", + files: [ + { path: "src/a.ts", operation: "update" }, + { path: "src/b.ts", operation: "update" }, + ], + })), + { title: "Edited 2 files", tone: "review" }, +); + +assert.deepEqual( + pickDisplay(getToolDisplay({ + tool: "show_changes", + files: [ + { path: "src/a.ts", operation: "add" }, + { path: "src/b.ts", operation: "update" }, + ], + })), + { title: "Changed 2 files", tone: "review" }, +); + +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.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: "worktree · 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 new file mode 100644 index 00000000..7a847631 --- /dev/null +++ b/src/ui/tool-display.ts @@ -0,0 +1,200 @@ +import { + isEditTool, + isPatchTool, + isReviewTool, + isShellTool, + isWriteTool, + summaryNumber, + type ToolResultCard, +} from "./card-types.js"; +import { toolIcons, type ToolIcon } from "./icons.js"; +import { getPatchDisplayParts } from "./patch-display.js"; + +export interface ToolDisplay { + icon: ToolIcon; + title: string; + label?: string; + tone: string; +} + +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: toolIcons.folderOpen, + title: "Opened workspace", + 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.iconOperation), + 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", + }; + case "write_stdin": + return { + icon: toolIcons.terminal, + title: processTitle(card, "process"), + label: processLabel(card), + tone: "shell", + }; + case "show_changes": { + const display = getPatchDisplayParts(card); + return { + icon: toolIcons.diff, + title: (card.files?.length ?? 0) > 0 ? display.title : "No changes", + 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 = [ + typeof summary.mode === "string" ? summary.mode : undefined, + 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(operation: ReturnType["iconOperation"]): ToolIcon { + if (operation === "add") return toolIcons.writeFile; + if (operation === "delete") return toolIcons.deleteFile; + if (operation === "move") return toolIcons.files; + return toolIcons.editFile; +} + +function singleFilePath(card: ToolResultCard): string | undefined { + if (card.files?.length === 1) return card.files[0]?.path ?? 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 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 28ba6bf5..ed81685c 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -3,6 +3,12 @@ font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); background: transparent; color: var(--color-text-primary, #f5f5f6); + --tool-card-border: color-mix(in srgb, var(--color-border-primary, #414141) 92%, transparent); + --tool-card-header-bg: color-mix(in srgb, var(--color-background-secondary, #272727) 96%, transparent); + --tool-card-body-bg: color-mix(in srgb, var(--color-background-primary, #181818) 98%, transparent); + --tool-card-hover-bg: color-mix(in srgb, var(--color-background-tertiary, #343434) 62%, transparent); + --tool-card-icon-bg: color-mix(in srgb, var(--color-background-primary, #0d0d0d) 98%, transparent); + --tool-card-divider: color-mix(in srgb, var(--color-border-primary, #414141) 82%, transparent); } * { @@ -25,9 +31,10 @@ body { .empty, .tool-card { width: 100%; - border: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 86%, transparent); - border-radius: 8px; - background: color-mix(in srgb, var(--color-background-secondary, #28282d) 92%, transparent); + overflow: hidden; + border: 1px solid var(--tool-card-border); + border-radius: 14px; + background: var(--tool-card-header-bg); box-shadow: none; color: var(--color-text-primary, #f5f5f6); } @@ -40,14 +47,14 @@ body { .tool-header { display: grid; - grid-template-columns: 38px minmax(0, 1fr) auto 28px; + grid-template-columns: 54px minmax(0, 1fr) auto 24px; align-items: center; - gap: 12px; + gap: 14px; width: 100%; - min-height: 64px; - padding: 10px 12px 10px 14px; + min-height: 82px; + padding: 12px 16px; border: 0; - border-radius: 8px; + border-radius: 13px; background: transparent; color: inherit; cursor: pointer; @@ -55,7 +62,7 @@ body { } .tool-header:hover:not(:disabled) { - background: color-mix(in srgb, var(--color-background-tertiary, #333338) 72%, transparent); + background: var(--tool-card-hover-bg); } .tool-header:disabled { @@ -64,92 +71,65 @@ body { .tool-icon { display: grid; - width: 38px; - height: 38px; + width: 54px; + height: 54px; place-items: center; - border: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 55%, transparent); - border-radius: 8px; - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--color-background-tertiary, #3a3a42) 72%, transparent), - color-mix(in srgb, var(--color-background-primary, #17181c) 90%, transparent) - ); + border: 1px solid color-mix(in srgb, var(--tool-card-border) 72%, transparent); + border-radius: 13px; + background: var(--tool-card-icon-bg); color: var(--color-text-primary, #f5f5f6); } .icon-svg { - width: 19px; - height: 19px; + width: 22px; + height: 22px; display: block; + stroke-width: 1.8; } .tool-main { display: grid; min-width: 0; - gap: 3px; + gap: 4px; } .tool-title { color: var(--color-text-primary, #f5f5f6); - font-size: var(--font-text-sm-size, 13px); - font-weight: 600; + font-size: var(--font-text-md-size, 16px); + font-weight: 500; + line-height: 1.25; } .tool-label { overflow: hidden; color: var(--color-text-secondary, #d6d6dc); font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: var(--font-text-sm-size, 14px); + font-size: var(--font-text-sm-size, 13px); + line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } .stats { display: inline-flex; - gap: 5px; + gap: 6px; align-items: center; font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); font-size: var(--font-text-sm-size, 13px); + font-variant-numeric: tabular-nums; white-space: nowrap; } -.badge-group { - display: inline-flex; - align-items: center; - gap: 6px; - white-space: nowrap; -} - -.badge { - display: inline-flex; - align-items: center; - gap: 5px; - min-height: 24px; - padding: 0 9px; - border: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 80%, transparent); - border-radius: 999px; - background: color-mix(in srgb, var(--color-background-primary, #17181c) 42%, transparent); +.header-meta { color: var(--color-text-secondary, #d6d6dc); - font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: var(--font-text-sm-size, 12px); + font-size: var(--font-text-sm-size, 13px); + line-height: 1.35; + text-align: right; white-space: nowrap; } -.badge.success { - border-color: color-mix(in srgb, var(--color-success-text, #6fda83) 44%, var(--color-border-primary, #3a3a40)); - color: var(--color-success-text, #6fda83); -} - -.badge.muted { - color: var(--color-text-tertiary, #a3a3aa); -} - -.badge-icon { - width: 13px; - height: 13px; - display: block; - flex: none; +.header-meta.empty { + width: 0; } .add { @@ -162,8 +142,8 @@ body { .chevron { display: grid; - width: 28px; - height: 28px; + width: 24px; + height: 24px; place-items: center; border-radius: 7px; color: var(--color-text-tertiary, #a3a3aa); @@ -174,7 +154,6 @@ body { } .tool-header:hover:not(:disabled) .chevron { - background: color-mix(in srgb, var(--color-background-primary, #17181c) 58%, transparent); color: var(--color-text-primary, #f5f5f6); } @@ -211,17 +190,12 @@ body { } .tool-body { - border-top: 1px solid var(--color-border-primary, #3a3a40); - background: var(--color-background-primary, #18191d); + border-top: 1px solid var(--tool-card-divider); + background: var(--tool-card-body-bg); } .review-header { - display: grid; - grid-template-columns: 38px minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - min-height: 58px; - padding: 10px 12px 8px 14px; + grid-template-columns: 54px minmax(0, 1fr) auto 24px; } .review-title-group { @@ -232,11 +206,10 @@ body { .review-summary { display: grid; - border-top: 1px solid var(--color-border-primary, #3a3a40); - background: var(--color-background-primary, #18191d); + border-top: 1px solid var(--tool-card-divider); + background: var(--tool-card-body-bg); } -.review-actions, .review-diff-file-stats { display: flex; align-items: center; @@ -248,47 +221,45 @@ body { font-size: var(--font-text-sm-size, 13px); } -.review-actions { - justify-content: flex-end; - padding: 10px 14px; - border-top: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 72%, transparent); -} - -.review-action { - min-height: 26px; - padding: 0 9px; - border: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 80%, transparent); - border-radius: 7px; - background: color-mix(in srgb, var(--color-background-primary, #17181c) 36%, transparent); +.review-more { + width: 100%; + min-height: 44px; + padding: 0 16px; + border: 0; + border-top: 1px solid var(--tool-card-divider); + background: transparent; color: var(--color-text-secondary, #d6d6dc); cursor: pointer; font: inherit; - font-size: var(--font-text-sm-size, 12px); + font-size: var(--font-text-sm-size, 13px); + text-align: left; } -.review-action:hover { - background: color-mix(in srgb, var(--color-background-tertiary, #333338) 80%, transparent); +.review-more:hover { + background: var(--tool-card-hover-bg); color: var(--color-text-primary, #f5f5f6); } .review-diff { display: grid; - gap: 8px; max-height: 520px; overflow: auto; - padding: 0 0 12px; } .review-diff-files { display: grid; - gap: 8px; - padding: 12px 14px 0; + gap: 0; + padding: 0; } .review-diff-file { overflow: hidden; - border: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 80%, transparent); - border-radius: 8px; + border: 0; + border-radius: 0; +} + +.review-diff-file + .review-diff-file { + border-top: 1px solid var(--tool-card-divider); } .review-diff-file-header { @@ -296,10 +267,10 @@ body { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; width: 100%; - min-height: 34px; - padding: 7px 10px; + min-height: 50px; + padding: 0 16px; border: 0; - background: color-mix(in srgb, var(--color-background-secondary, #28282d) 88%, transparent); + background: transparent; color: var(--color-text-primary, #f5f5f6); cursor: pointer; font: inherit; @@ -307,21 +278,27 @@ body { } .review-diff-file-header:hover { - background: color-mix(in srgb, var(--color-background-tertiary, #333338) 84%, transparent); + background: var(--tool-card-hover-bg); } .review-diff-file-name, .review-diff-file-stats { overflow: hidden; - font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: var(--font-text-sm-size, 12px); + font-size: var(--font-text-sm-size, 14px); text-overflow: ellipsis; white-space: nowrap; } +.review-diff-file-name { + font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); +} + .review-diff-file-stats { justify-content: flex-end; overflow: visible; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: var(--font-text-sm-size, 13px); + font-variant-numeric: tabular-nums; } .status { @@ -373,13 +350,24 @@ body { @media (max-width: 520px) { .tool-header { - grid-template-columns: 32px minmax(0, 1fr) auto 18px; - gap: 8px; - padding: 8px; + grid-template-columns: 42px minmax(0, 1fr) auto 18px; + gap: 10px; + min-height: 68px; + padding: 10px 12px; } .tool-icon { - width: 32px; - height: 32px; + width: 42px; + height: 42px; + border-radius: 10px; + } + + .chevron { + width: 18px; + height: 18px; + } + + .review-header { + grid-template-columns: 42px minmax(0, 1fr) auto 18px; } } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index edbb620f..78723864 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -11,28 +11,22 @@ import { isPatchTool, isReadTool, isReviewTool, - isSearchTool, - isShellTool, isToolName, isToolResultCard, isWriteTool, payloadText, - summaryNumber, type HostContext, - type PatchOperation, type ToolName, type ToolResultCard, } from "./card-types.js"; -import { getPatchDisplayParts } from "./patch-display.js"; +import { renderIcon, toolIcons } from "./icons.js"; +import { + getToolDisplay, + getToolHeaderSummary, + type ToolDisplay, +} from "./tool-display.js"; import "./workspace-app.css"; -interface ToolDisplay { - icon: string; - title: string; - label: string; - tone: string; -} - interface MountedPayload { update(options: { card: ToolResultCard; @@ -89,8 +83,9 @@ async function boot(): Promise { return; } - card = { ...structured, tool }; - expanded = false; + const nextCard = { ...structured, tool }; + card = nextCard; + expanded = isReviewTool(tool) && isExpandableCard(nextCard); reviewFilesExpanded = false; errorMessage = null; render(); @@ -182,21 +177,23 @@ function render(): void { } const icon = element("span", { className: "tool-icon", ariaHidden: "true" }); - icon.innerHTML = display.icon; + icon.append(renderIcon(display.icon)); const toolMain = element("span", { className: "tool-main" }); const title = element("span", { className: "tool-title", text: display.title }); - const label = element("span", { - className: "tool-label", - text: display.label, - title: display.label, - }); - toolMain.append(title, label); + toolMain.append(title); + if (display.label) { + toolMain.append(element("span", { + className: "tool-label", + text: display.label, + title: display.label, + })); + } button.append( icon, toolMain, - renderSummaryBadge(card), + renderHeaderSummary(card), renderChevron(expanded, expandable), ); section.append(button); @@ -219,7 +216,7 @@ function renderEmpty(message: string, tone: "muted" | "error" = "muted"): void { } async function renderPayloadIfNeeded(): Promise { - if (!card || !currentPayloadContainer || (!expanded && !isReviewTool(card.tool))) return; + if (!card || !currentPayloadContainer || !expanded) return; const target = currentPayloadContainer; @@ -330,102 +327,90 @@ function renderPrePayload( container.replaceChildren(element("pre", { className: `text-payload ${tool}`, text })); } -function renderSummaryBadge(card: ToolResultCard): HTMLElement { - const summary = card.summary ?? {}; - - if (isReviewTool(card.tool)) { - const stats = element("span", { className: "stats" }); - stats.setAttribute("aria-label", "Review diff statistics"); - stats.append( - element("span", { className: "add", text: `+${String(summary.additions ?? 0)}` }), - element("span", { className: "remove", text: `-${String(summary.removals ?? 0)}` }), - ); - return stats; - } +function renderHeaderSummary(card: ToolResultCard): HTMLElement { + const summary = getToolHeaderSummary(card); - if (isPatchTool(card.tool) || isEditTool(card.tool) || isWriteTool(card.tool)) { + if (summary.kind === "diff") { const stats = element("span", { className: "stats" }); stats.setAttribute("aria-label", "Diff statistics"); stats.append( - element("span", { className: "add", text: `+${String(summary.additions ?? 0)}` }), - element("span", { className: "remove", text: `-${String(summary.removals ?? 0)}` }), + element("span", { className: "add", text: `+${String(summary.additions)}` }), + element("span", { className: "remove", text: `-${String(summary.removals)}` }), ); return stats; } - if (card.tool === "open_workspace") { - const agentsFiles = summaryNumber(summary, "agentsFiles") ?? 0; - const skills = summaryNumber(summary, "skills") ?? 0; - const group = element("span", { className: "badge-group" }); - group.setAttribute("aria-label", "Workspace summary"); - - const agentsBadge = element("span", { - className: `badge ${agentsFiles > 0 ? "success" : "muted"}`, - text: agentsFiles > 0 ? "AGENTS.md" : "No AGENTS.md", - }); - if (agentsFiles > 0) { - agentsBadge.insertAdjacentHTML("afterbegin", checkCircleIcon()); - } - - group.append(agentsBadge, element("span", { className: "badge", text: `${skills} skills` })); - return group; - } - - if (isShellTool(card.tool)) { - const state = summary.running === true ? "running" : "ran"; - return element("span", { - className: "badge", - text: `${state} · ${String(summary.lines ?? 0)} lines`, - }); - } - - if (isSearchTool(card.tool)) { - return element("span", { className: "badge", text: `${String(summary.lines ?? 0)} lines` }); - } - - return element("span", { className: "badge", text: `${String(summary.lines ?? 0)} lines` }); + const meta = element("span", { + className: `header-meta ${summary.kind === "empty" ? "empty" : ""}`, + text: summary.kind === "text" ? summary.text : "", + }); + if (summary.kind === "empty") meta.setAttribute("aria-hidden", "true"); + return meta; } function renderReviewCard(card: ToolResultCard, display: ToolDisplay): void { unmountPayload(); const files = card.files ?? []; - const summary = card.summary ?? {}; const visibleFiles = reviewFilesExpanded ? files : files.slice(0, 3); const hiddenCount = Math.max(0, files.length - visibleFiles.length); + const expandable = isExpandableCard(card); const main = element("main", { className: "shell" }); const section = element("section", { className: "tool-card review" }); - const header = element("div", { className: "review-header" }); - const icon = element("span", { className: "tool-icon", ariaHidden: "true" }); - icon.innerHTML = display.icon; - const titleGroup = element("div", { className: "review-title-group" }); - - titleGroup.append( - element("span", { className: "tool-title", text: display.title }), - element("span", { className: "tool-label", text: display.label, title: display.label }), - ); - header.append(icon, titleGroup, renderSummaryBadge(card)); - - const body = element("div", { className: "review-summary" }); - currentPayloadContainer = body; + const header = element("button", { + className: "tool-header review-header", + type: "button", + ariaExpanded: String(expanded), + disabled: !expandable, + }); - const actions = element("div", { className: "review-actions" }); - if (hiddenCount > 0) { - const showMore = element("button", { - className: "review-action", - type: "button", - text: `Show ${hiddenCount} more ${hiddenCount === 1 ? "file" : "files"}`, - }); - showMore.addEventListener("click", () => { - reviewFilesExpanded = true; + if (expandable) { + header.addEventListener("click", () => { + expanded = !expanded; render(); }); - actions.append(showMore); } - section.append(header, body); - if (actions.childElementCount > 0) { - section.append(actions); + const icon = element("span", { className: "tool-icon", ariaHidden: "true" }); + icon.append(renderIcon(display.icon)); + const titleGroup = element("span", { className: "tool-main review-title-group" }); + + titleGroup.append(element("span", { className: "tool-title", text: display.title })); + if (display.label) { + titleGroup.append(element("span", { + className: "tool-label", + text: display.label, + title: display.label, + })); + } + header.append( + icon, + titleGroup, + renderHeaderSummary(card), + renderChevron(expanded, expandable), + ); + + section.append(header); + if (expanded) { + const body = element("div", { className: "review-summary" }); + const payload = element("div", { className: "review-payload" }); + currentPayloadContainer = payload; + body.append(payload); + + if (hiddenCount > 0) { + const showMore = element("button", { + className: "review-more", + type: "button", + text: `Show ${hiddenCount} more ${hiddenCount === 1 ? "file" : "files"}`, + }); + showMore.addEventListener("click", () => { + reviewFilesExpanded = true; + render(); + }); + body.append(showMore); + } + + section.append(body); } main.append(section); @@ -440,7 +425,7 @@ function renderChevron(isExpanded: boolean, visible: boolean): HTMLElement { }); if (visible) { - chevron.innerHTML = iconSvg(''); + chevron.append(renderIcon(toolIcons.chevronDown)); } return chevron; @@ -452,9 +437,9 @@ function setPayloadLoading(container: HTMLElement, loading: boolean): void { if (!chevron) return; chevron.classList.toggle("loading", loading); - chevron.innerHTML = loading - ? iconSvg('') - : iconSvg(''); + chevron.replaceChildren( + renderIcon(loading ? toolIcons.loading : toolIcons.chevronDown), + ); const button = header instanceof HTMLButtonElement ? header : null; if (button) button.setAttribute("aria-busy", String(loading)); @@ -493,72 +478,6 @@ function formatAgentsFilesForPayload( .join("\n\n"); } -function getPatchToolDisplay(card: ToolResultCard, label: string): ToolDisplay { - const display = getPatchDisplayParts(card); - - return { - icon: patchIcon(display.iconOperation), - title: display.title, - label, - tone: display.tone, - }; -} - -function patchIcon(operation: PatchOperation | undefined): string { - if (operation === "add") return filePlusIcon(); - if (operation === "delete") return fileIcon(); - if (operation === "move") return filesIcon(); - return editIcon(); -} - -function getToolDisplay(card: ToolResultCard): ToolDisplay { - const label = getToolLabel(card); - - switch (card.tool) { - case "open_workspace": - return { icon: folderIcon(), title: "Workspace", label, tone: "workspace" }; - case "read": - return { icon: fileIcon(), title: "Read File", label, tone: "read" }; - case "write": - return { icon: filePlusIcon(), title: "Write File", label, tone: "write" }; - case "edit": - return { icon: editIcon(), title: "Edit File", label, tone: "edit" }; - case "apply_patch": - return getPatchToolDisplay(card, label); - case "grep": - return { icon: searchIcon(), title: "Grep", label, tone: "search" }; - case "glob": - return { icon: filesIcon(), title: "Glob", label, tone: "search" }; - case "ls": - return { icon: listIcon(), title: "List Directory", label, tone: "directory" }; - case "bash": - return { icon: terminalIcon(), title: "Bash", label, tone: "shell" }; - case "exec_command": - return { icon: terminalIcon(), title: "Exec Command", label, tone: "shell" }; - case "write_stdin": - return { icon: terminalIcon(), title: "Process Session", label, tone: "shell" }; - case "show_changes": - return { icon: reviewIcon(), title: "Show Changes", label, tone: "review" }; - } -} - -function getToolLabel(card: ToolResultCard): string { - if (isShellTool(card.tool)) { - return String(card.summary?.command ?? card.summary?.sessionId ?? card.path ?? card.tool); - } - if (isReviewTool(card.tool)) { - const count = Number(card.summary?.files ?? card.files?.length ?? 0); - return count === 0 ? "No changes since last review" : `${count} changed ${count === 1 ? "file" : "files"}`; - } - if (card.path) return card.path; - if (card.root) return card.root; - if (isSearchTool(card.tool)) { - return String(card.summary?.pattern ?? card.tool); - } - - return card.tool; -} - function toolNameFromMeta(result: CallToolResult): ToolName | undefined { const meta = result._meta as Record | undefined; const tool = meta?.tool; @@ -600,46 +519,3 @@ function element( return node; } -function iconSvg(children: string): string { - return ``; -} - -function folderIcon(): string { - return iconSvg(''); -} - -function fileIcon(): string { - return iconSvg(''); -} - -function filePlusIcon(): string { - return iconSvg(''); -} - -function editIcon(): string { - return iconSvg(''); -} - -function searchIcon(): string { - return iconSvg(''); -} - -function filesIcon(): string { - return iconSvg(''); -} - -function checkCircleIcon(): string { - return ''; -} - -function listIcon(): string { - return iconSvg(''); -} - -function terminalIcon(): string { - return iconSvg(''); -} - -function reviewIcon(): string { - return iconSvg(''); -}