diff --git a/src/apply-patch.test.ts b/src/apply-patch.test.ts index c6f08695..3a846f4f 100644 --- a/src/apply-patch.test.ts +++ b/src/apply-patch.test.ts @@ -191,13 +191,16 @@ assert.throws( const overwriteRoot = await mkdtemp(join(tmpdir(), "devspace-apply-patch-overwrite-")); await writeFile(join(overwriteRoot, "duplicate.txt"), "old content\n"); -await applyPatch( +const overwriteResult = await applyPatch( overwriteRoot, `*** Begin Patch *** Add File: duplicate.txt +new content *** End Patch`, ); +assert.deepEqual(overwriteResult.files, [ + { path: "duplicate.txt", operation: "update" }, +]); assert.equal(await readFile(join(overwriteRoot, "duplicate.txt"), "utf8"), "new content\n"); await writeFile(join(overwriteRoot, "source.txt"), "from\n"); diff --git a/src/apply-patch.ts b/src/apply-patch.ts index 32ec4a46..05a73de6 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -365,7 +365,7 @@ export async function applyPatch(root: string, patch: string): Promise { diff --git a/src/server.ts b/src/server.ts index bfb8fb4f..37ec4165 100644 --- a/src/server.ts +++ b/src/server.ts @@ -914,7 +914,6 @@ export function createMcpServer( skills: cardSkills, agentProviders: cardAgentProviders, agents: cardAgents, - skillDiagnostics: workspace.skillDiagnostics, instruction: cardInstruction, summary: { mode: workspace.mode, @@ -923,7 +922,6 @@ export function createMcpServer( skills: cardSkills.length, agentProviders: cardAgentProviders.length, agents: cardAgents.length, - skillDiagnostics: workspace.skillDiagnostics.length, }, }, }, diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index eb16ad87..3905ecd9 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { isEditTool, isExpandableCard, + isInitiallyExpandedCard, isPatchTool, isShellTool, isToolName, @@ -31,6 +32,42 @@ test("a patch card expands only when it contains patch content", () => { 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({ diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 596e2e84..107f3063 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -17,6 +17,12 @@ export type ToolName = export type HostContext = NonNullable>; export type PatchOperation = "add" | "update" | "delete" | "move"; +export type ReviewFileType = + | "change" + | "rename-pure" + | "rename-changed" + | "new" + | "deleted"; export interface ToolResultCard { tool: ToolName; @@ -41,7 +47,7 @@ export interface ToolResultCard { path?: string; previousPath?: string; operation?: PatchOperation; - type?: string; + type?: ReviewFileType; additions?: number; removals?: number; }>; @@ -72,7 +78,6 @@ export interface ToolResultCard { providerAvailable?: boolean; providerUnavailableReason?: string; }>; - skillDiagnostics?: unknown[]; instruction?: string; } @@ -165,15 +170,13 @@ export function isExpandableCard(card: ToolResultCard): boolean { Number(card.summary?.skills ?? 0) > 0 || Number(card.summary?.agentProviders ?? 0) > 0 || Number(card.summary?.agents ?? 0) > 0 || - Number(card.summary?.skillDiagnostics ?? 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) || - Boolean(card.skillDiagnostics?.length) + Boolean(card.instruction) ); } @@ -182,3 +185,11 @@ export function isExpandableCard(card: ToolResultCard): boolean { return Boolean(card.payload); } + +export function isInitiallyExpandedCard(card: ToolResultCard): boolean { + if (isReviewTool(card.tool)) return isExpandableCard(card); + if (isPatchTool(card.tool)) { + return card.files?.length === 1 && isExpandableCard(card); + } + return false; +} diff --git a/src/ui/heavy-payload.tsx b/src/ui/heavy-payload.tsx index 69779141..a61e6dac 100644 --- a/src/ui/heavy-payload.tsx +++ b/src/ui/heavy-payload.tsx @@ -12,6 +12,7 @@ import { type HostContext, type ToolResultCard, } from "./card-types.js"; +import { pierrePrettyScrollbarCss } from "./scrollbar.js"; type ThemeType = "light" | "dark"; @@ -77,7 +78,7 @@ function HeavyPayload({ ); } - return
{text}
; + return
{text}
; } function FilePayload({ @@ -100,6 +101,7 @@ function FilePayload({ }, themeType, overflow: "scroll", + unsafeCSS: pierrePrettyScrollbarCss, }), [themeType], ); @@ -134,7 +136,7 @@ function FilePayload({ }; }, [fileOptions, path, startLine, text]); - return
; + return
; } function DiffPayload({ @@ -158,12 +160,13 @@ function DiffPayload({ hunkSeparators: "line-info", lineDiffType: "word-alt", overflow: "scroll", + unsafeCSS: pierrePrettyScrollbarCss, collapsedContextThreshold: 4, expansionLineCount: 20, stickyHeader: true, disableFileHeader: true, }} - className="pierre-diff" + className="pierre-diff pretty-scrollbar" /> ); } diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 22f86002..1713e0f8 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -1,13 +1,21 @@ import { + Blocks, + Bot, ChevronDown, + CircleAlert, + Cpu, FileDiff, + FileCheck2, FileMinus, FilePenLine, FilePlus, FileText, Files, + FolderGit2, FolderOpen, FolderTree, + GitBranch, + GitCommitHorizontal, LoaderCircle, Search, SquareTerminal, @@ -17,6 +25,8 @@ import { } from "lucide"; export const toolIcons = { + agents: Bot, + base: GitCommitHorizontal, chevronDown: ChevronDown, deleteFile: FileMinus, diff: FileDiff, @@ -24,11 +34,19 @@ export const toolIcons = { 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; diff --git a/src/ui/patch-display.test.ts b/src/ui/patch-display.test.ts index ea1b268b..612809ff 100644 --- a/src/ui/patch-display.test.ts +++ b/src/ui/patch-display.test.ts @@ -1,5 +1,10 @@ import assert from "node:assert/strict"; -import { getPatchDisplayParts } from "./patch-display.js"; +import { + getFileChangePathDisplay, + getPatchDisplayParts, + getRenderedFileChangeKind, + getRenderedFileChangePathDisplay, +} from "./patch-display.js"; assert.deepEqual(getPatchDisplayParts({}), { title: "Applied patch", @@ -10,7 +15,7 @@ assert.deepEqual( getPatchDisplayParts({ files: [{ path: "created.ts", operation: "add" }] }), { title: "Added 1 file", - iconOperation: "add", + iconKind: "added", tone: "write", }, ); @@ -24,11 +29,141 @@ assert.deepEqual( }), { title: "Added 2 files", - iconOperation: "add", + 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: "same.tmp", type: "deleted" }, + 1, + ), + "deleted", +); + +assert.equal( + getRenderedFileChangeKind( + [{ path: "report.md", operation: "add" }], + { path: "report.md", type: "change" }, + 0, + ), + "edited", +); + +assert.equal( + getRenderedFileChangeKind( + [{ path: "renamed.md", previousPath: "old.md", operation: "move" }], + { path: "renamed.md", type: "change" }, + 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: [ diff --git a/src/ui/patch-display.ts b/src/ui/patch-display.ts index cd8f7791..ec1f7ad2 100644 --- a/src/ui/patch-display.ts +++ b/src/ui/patch-display.ts @@ -1,42 +1,163 @@ -import type { PatchOperation, ToolResultCard } from "./card-types.js"; +import type { ToolResultCard } from "./card-types.js"; + +export type FileChangeKind = + | "added" + | "edited" + | "deleted" + | "renamed" + | "renamed-edited" + | "unknown"; + +type ToolResultFile = NonNullable[number]; export interface PatchDisplayParts { title: string; - iconOperation?: PatchOperation; - tone: "edit" | "write"; + iconKind?: FileChangeKind; + tone: "edit" | "write" | "delete"; +} + +export interface FileChangePathDisplay { + current: string; + previous?: string; + title: string; } -const patchOperationLabels: Record = { - add: "Added", - update: "Edited", - delete: "Deleted", - move: "Moved", +const fileChangeLabels: Record, string> = { + added: "Added", + edited: "Edited", + deleted: "Deleted", + renamed: "Renamed", + "renamed-edited": "Renamed and edited", }; -export function getPatchDisplayParts(card: Pick): PatchDisplayParts { +export function getPatchDisplayParts( + card: Pick, + options: { emptyTitle?: string } = {}, +): PatchDisplayParts { const files = card.files ?? []; - const operations = patchOperations(files); + const fileCount = countChangedFiles(files); - if (operations.length === 0) { - return { title: "Applied patch", tone: "edit" }; + if (fileCount === 0) { + return { title: options.emptyTitle ?? "Applied patch", tone: "edit" }; } - const singleOperation = operations.length === 1 ? operations[0] : undefined; - + const kinds = new Set(files.map(getFileChangeKind)); + const singleKind = kinds.size === 1 ? [...kinds][0] : undefined; const display: PatchDisplayParts = { - title: patchTitle(operations, countChangedFiles(files)), - tone: singleOperation === "add" ? "write" : "edit", + title: changeTitle(singleKind, fileCount), + tone: changeTone(singleKind), }; - if (singleOperation) display.iconOperation = singleOperation; + + if (singleKind && singleKind !== "unknown") display.iconKind = singleKind; return display; } -function patchOperations(files: NonNullable): PatchOperation[] { - const operations = new Set(); - for (const file of files) { - if (file.operation) operations.add(file.operation); +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"; + } + + switch (file.type) { + case "new": + return "added"; + case "change": + return "edited"; + case "deleted": + return "deleted"; + case "rename-pure": + return "renamed"; + case "rename-changed": + return "renamed-edited"; + default: + return "unknown"; } - return [...operations]; +} + +export function getRenderedFileChangeKind( + files: NonNullable, + 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"; +} + +export function getFileChangePathDisplay( + 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 }; + } + + const sameDirectory = pathDirectory(previous) === pathDirectory(current); + return { + current: sameDirectory ? pathBasename(current) : current, + previous: sameDirectory ? pathBasename(previous) : previous, + title: `${previous} → ${current}`, + }; +} + +export function getRenderedFileChangePathDisplay( + files: NonNullable, + 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) + )); + const cardFile = matchedFile ?? indexedFile; + + return getFileChangePathDisplay({ + path: parsedFile.path ?? cardFile?.path, + previousPath: parsedFile.previousPath ?? cardFile?.previousPath, + }); +} + +export function fileChangeKindLabel(kind: FileChangeKind): string { + return kind === "unknown" ? "Changed" : fileChangeLabels[kind]; } function countChangedFiles(files: NonNullable): number { @@ -55,14 +176,30 @@ function countChangedFiles(files: NonNullable): number return paths.size + unnamedFiles; } -function patchTitle(operations: PatchOperation[], fileCount: number): string { - if (operations.length === 1) { - return `${patchOperationLabels[operations[0]]} ${fileCount} ${fileNoun(fileCount)}`; +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); +} + +function pathBasename(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex === -1 ? path : path.slice(separatorIndex + 1); +} diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index 068d2c1f..455e5472 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -3,6 +3,13 @@ import { createRoot } from "react-dom/client"; import { parsePatchFiles, type FileDiffMetadata, type FileDiffOptions } from "@pierre/diffs"; import { FileDiff } from "@pierre/diffs/react"; import type { HostContext, ToolResultCard } from "./card-types.js"; +import { + fileChangeKindLabel, + getRenderedFileChangePathDisplay, + getRenderedFileChangeKind, + type FileChangeKind, +} from "./patch-display.js"; +import { pierrePrettyScrollbarCss } from "./scrollbar.js"; type ThemeType = "light" | "dark"; @@ -55,13 +62,42 @@ function ReviewPayload({ const options = diffOptions(themeType); + if (files.length === 1) { + return ( +
+ +
+ ); + } + return ( -
+
{visibleFiles.map((fileDiff, index) => { const key = fileDiff.cacheKey ?? `${fileDiff.prevName ?? ""}->${fileDiff.name}-${index}`; const stats = diffStats(fileDiff); const isOpen = openFiles.has(key); + const changeKind = getRenderedFileChangeKind( + card.files ?? [], + { + path: fileDiff.name, + previousPath: fileDiff.prevName, + type: fileDiff.type, + }, + index, + ); + const pathDisplay = getRenderedFileChangePathDisplay( + card.files ?? [], + { + path: fileDiff.name, + previousPath: fileDiff.prevName, + }, + index, + ); return (
@@ -79,14 +115,43 @@ function ReviewPayload({ setOpenFiles(next); }} > - {fileDiff.name} + + {fileChangeSymbol(changeKind)} + + {pathDisplay?.previous ? ( + + + {pathDisplay.previous} + + + + {pathDisplay.current} + + + ) : ( + + {pathDisplay?.current ?? fileDiff.name} + + )} +{stats.additions} -{stats.removals} {isOpen ? ( - + ) : null}
); @@ -96,6 +161,22 @@ function ReviewPayload({ ); } +function fileChangeSymbol(kind: FileChangeKind): string { + switch (kind) { + case "added": + return "A"; + case "edited": + return "M"; + case "deleted": + return "D"; + case "renamed": + case "renamed-edited": + return "R"; + case "unknown": + return "•"; + } +} + function parseFiles(patch: string | undefined): FileDiffMetadata[] { if (!patch) return []; return parsePatchFiles(patch, "review", true).flatMap((parsedPatch) => parsedPatch.files); @@ -123,6 +204,7 @@ function diffOptions(themeType: ThemeType): FileDiffOptions { hunkSeparators: "line-info", lineDiffType: "word-alt", overflow: "scroll", + unsafeCSS: pierrePrettyScrollbarCss, collapsedContextThreshold: 4, expansionLineCount: 20, stickyHeader: false, diff --git a/src/ui/scrollbar.ts b/src/ui/scrollbar.ts new file mode 100644 index 00000000..0b3cb1a2 --- /dev/null +++ b/src/ui/scrollbar.ts @@ -0,0 +1,44 @@ +export const pierrePrettyScrollbarCss = ` +:host { + --diffs-scrollbar-gutter-override: 12px; +} + +[data-code] { + scrollbar-gutter: auto; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb, rgb(128 128 128 / 55%)) transparent; +} + +@supports selector(::-webkit-scrollbar) { + [data-code]::-webkit-scrollbar { + width: 12px; + height: 12px; + } + + [data-code]::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; + } + + [data-code]::-webkit-scrollbar-track { + background: transparent; + } + + [data-code]::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb, rgb(128 128 128 / 55%)); + background-clip: content-box; + border: 4px solid transparent; + border-radius: 9999px; + } + + [data-code]::-webkit-scrollbar-thumb:hover, + [data-code]::-webkit-scrollbar-thumb:active { + background-color: var(--scrollbar-thumb-hover, rgb(112 112 112 / 85%)); + } + + [data-code]::-webkit-scrollbar-corner { + background: transparent; + } +} +`; diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index 86b67271..71d3504d 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -6,7 +6,8 @@ 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 worktree", 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" }], @@ -27,17 +28,37 @@ for (const [card, expected] of displayCases) { } 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", operation: "update" }, - { path: "src/b.ts", operation: "update" }, + { path: "src/a.ts", type: "change" }, + { path: "src/b.ts", type: "change" }, ], })), { title: "Edited 2 files", tone: "review" }, @@ -47,13 +68,28 @@ assert.deepEqual( pickDisplay(getToolDisplay({ tool: "show_changes", files: [ - { path: "src/a.ts", operation: "add" }, - { path: "src/b.ts", operation: "update" }, + { 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", @@ -66,6 +102,18 @@ 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" } })), @@ -95,7 +143,7 @@ assert.deepEqual( tool: "open_workspace", summary: { mode: "worktree", agentsFiles: 1, skills: 4 }, }), - { kind: "text", text: "worktree · 1 instruction · 4 skills" }, + { kind: "text", text: "1 instruction · 4 skills" }, ); assert.deepEqual( diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index f9706690..be64e0b0 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -8,13 +8,17 @@ import { type ToolResultCard, } from "./card-types.js"; import { toolIcons, type ToolIcon } from "./icons.js"; -import { getPatchDisplayParts } from "./patch-display.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 = @@ -26,12 +30,8 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { switch (card.tool) { case "open_workspace": return { - icon: toolIcons.folderOpen, - title: card.workspaceReused - ? "Reused workspace" - : card.mode === "worktree" - ? "Opened worktree" - : "Opened workspace", + icon: card.mode === "worktree" ? toolIcons.gitBranch : toolIcons.folderOpen, + title: workspaceTitle(card), label: card.root ?? card.path, tone: "workspace", }; @@ -59,7 +59,7 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { case "apply_patch": { const display = getPatchDisplayParts(card); return { - icon: patchIcon(display.iconOperation), + icon: patchIcon(display.iconKind), title: display.title, label: singleFilePath(card), tone: display.tone, @@ -94,6 +94,7 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { title: processTitle(card, "command"), label: processLabel(card), tone: "shell", + state: processState(card), }; case "write_stdin": return { @@ -101,12 +102,17 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { title: processTitle(card, "process"), label: processLabel(card), tone: "shell", + state: processState(card), }; case "show_changes": { - const display = getPatchDisplayParts(card); + const display = getPatchDisplayParts(card, { emptyTitle: "Changes ready" }); + const fileCount = card.files?.length ?? 0; return { icon: toolIcons.diff, - title: (card.files?.length ?? 0) > 0 ? display.title : "No changes", + title: fileCount > 0 || card.payload?.patch + ? display.title + : "No changes", + label: singleFilePath(card), tone: "review", }; } @@ -126,7 +132,6 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { 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)); @@ -149,15 +154,21 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { 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; +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 card.files[0]?.path ?? card.path; + if (card.files?.length === 1) { + return getFileChangePathDisplay(card.files[0])?.title ?? card.path; + } return undefined; } @@ -181,6 +192,13 @@ function processTitle(card: ToolResultCard, subject: "command" | "process"): str 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; diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 65713c34..8a6ef00b 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -3,12 +3,14 @@ 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); + --tool-card-border: color-mix(in srgb, var(--color-border-primary, #414141) 74%, transparent); + --tool-card-header-bg: color-mix(in srgb, var(--color-background-secondary, #272727) 88%, transparent); + --tool-card-body-bg: color-mix(in srgb, var(--color-background-primary, #181818) 94%, transparent); + --tool-card-hover-bg: color-mix(in srgb, var(--color-background-tertiary, #343434) 46%, transparent); + --tool-card-divider: color-mix(in srgb, var(--color-border-primary, #414141) 66%, transparent); + --tool-accent: var(--color-text-secondary, #b6b6bd); + --scrollbar-thumb: color-mix(in srgb, var(--color-text-tertiary, #8a8a8a) 56%, transparent); + --scrollbar-thumb-hover: color-mix(in srgb, var(--color-text-secondary, #a8a8a8) 82%, transparent); } * { @@ -30,20 +32,86 @@ body { .empty, .tool-card { + --tool-accent-soft: color-mix(in srgb, var(--tool-accent) 12%, transparent); width: 100%; overflow: hidden; border: 1px solid var(--tool-card-border); - border-radius: 14px; + border-radius: 12px; background: var(--tool-card-header-bg); box-shadow: none; color: var(--color-text-primary, #f5f5f6); } -.tool-card, -.tool-card * { - scrollbar-width: thin; - scrollbar-color: gray transparent; - scrollbar-gutter: stable; +.tool-card.workspace, +.tool-card.directory { + --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; + height: 12px; + } + + .pretty-scrollbar::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; + } + + .pretty-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + + .pretty-scrollbar::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb); + background-clip: content-box; + border: 4px solid transparent; + border-radius: 9999px; + } + + .pretty-scrollbar::-webkit-scrollbar-thumb:hover, + .pretty-scrollbar::-webkit-scrollbar-thumb:active { + background-color: var(--scrollbar-thumb-hover); + } + + .pretty-scrollbar::-webkit-scrollbar-corner { + background: transparent; + } } .empty { @@ -54,20 +122,27 @@ body { .tool-header { display: grid; - grid-template-columns: 54px minmax(0, 1fr) auto 24px; + grid-template-columns: 40px minmax(0, 1fr) auto 20px; align-items: center; - gap: 14px; + gap: 12px; width: 100%; - min-height: 82px; - padding: 12px 16px; + min-height: 64px; + padding: 10px 12px; border: 0; - border-radius: 13px; + border-radius: 11px; background: transparent; color: inherit; cursor: pointer; text-align: left; } +.tool-header:focus-visible, +.review-diff-file-header:focus-visible, +.review-more:focus-visible { + outline: 2px solid color-mix(in srgb, var(--tool-accent) 72%, transparent); + outline-offset: -2px; +} + .tool-header:hover:not(:disabled) { background: var(--tool-card-hover-bg); } @@ -78,18 +153,18 @@ body { .tool-icon { display: grid; - width: 54px; - height: 54px; + width: 40px; + height: 40px; place-items: center; - 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); + border: 1px solid color-mix(in srgb, var(--tool-accent) 18%, transparent); + border-radius: 10px; + background: var(--tool-accent-soft); + color: var(--tool-accent); } .icon-svg { - width: 22px; - height: 22px; + width: 20px; + height: 20px; display: block; stroke-width: 1.8; } @@ -97,39 +172,39 @@ body { .tool-main { display: grid; min-width: 0; - gap: 4px; + gap: 2px; } .tool-title { color: var(--color-text-primary, #f5f5f6); - font-size: var(--font-text-md-size, 16px); - font-weight: 500; - line-height: 1.25; + font-size: var(--font-text-sm-size, 14px); + font-weight: 550; + line-height: 1.3; } .tool-label { overflow: hidden; - color: var(--color-text-secondary, #d6d6dc); + color: var(--color-text-tertiary, #a3a3aa); font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: var(--font-text-sm-size, 13px); - line-height: 1.35; + font-size: var(--font-text-xs-size, 12px); + line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; } .stats { display: inline-flex; - gap: 6px; + gap: 5px; align-items: center; font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: var(--font-text-sm-size, 13px); + font-size: var(--font-text-xs-size, 12px); font-variant-numeric: tabular-nums; white-space: nowrap; } .header-meta { - color: var(--color-text-secondary, #d6d6dc); - font-size: var(--font-text-sm-size, 13px); + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-sm-size, 12px); line-height: 1.35; text-align: right; white-space: nowrap; @@ -149,8 +224,8 @@ body { .chevron { display: grid; - width: 24px; - height: 24px; + width: 20px; + height: 20px; place-items: center; border-radius: 7px; color: var(--color-text-tertiary, #a3a3aa); @@ -165,8 +240,8 @@ body { } .chevron .icon-svg { - width: 16px; - height: 16px; + width: 15px; + height: 15px; } .chevron.expanded { @@ -201,8 +276,379 @@ body { background: var(--tool-card-body-bg); } +.workspace-details { + display: grid; + max-height: 420px; + overflow: auto; +} + +.workspace-rows { + display: grid; + padding: 4px 0; +} + +.workspace-row { + display: grid; + grid-template-columns: 22px minmax(116px, 0.24fr) minmax(0, 1fr); + gap: 10px; + min-height: 40px; + align-items: center; + padding: 7px 12px; +} + +.workspace-row-icon { + display: grid; + width: 22px; + height: 22px; + place-items: center; + border-radius: 6px; + background: color-mix(in srgb, var(--tool-accent) 9%, transparent); + color: color-mix(in srgb, var(--tool-accent) 72%, var(--color-text-tertiary, #a3a3aa)); +} + +.workspace-row-icon-svg { + width: 14px; + height: 14px; + stroke-width: 1.8; +} + +.workspace-key { + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-sm-size, 12px); + font-weight: 500; +} + +.workspace-value { + min-width: 0; + overflow: hidden; + color: var(--color-text-secondary, #c7c7ce); + font-size: var(--font-text-sm-size, 12px); + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-value.mono { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); +} + +.workspace-base-value { + display: flex; + min-width: 0; + align-items: center; + gap: 7px; +} + +.workspace-base-value .workspace-value { + flex: 0 1 auto; +} + +.workspace-base-warning { + display: grid; + width: 18px; + height: 18px; + flex: 0 0 auto; + place-items: center; + color: var(--color-warning-text, #e6b566); + cursor: help; +} + +.workspace-base-warning-svg { + width: 14px; + height: 14px; + stroke-width: 2; +} + +.workspace-chip-list { + display: flex; + min-width: 0; + flex-wrap: nowrap; + align-items: center; + gap: 6px; + overflow: hidden; +} + +.workspace-chip { + display: inline-flex; + flex: 0 0 auto; + max-width: 100%; + min-height: 24px; + align-items: center; + overflow: hidden; + padding: 3px 8px; + border: 1px solid color-mix(in srgb, var(--tool-accent) 16%, var(--tool-card-divider)); + border-radius: 9999px; + background: color-mix(in srgb, var(--tool-accent) 7%, transparent); + color: var(--color-text-secondary, #c7c7ce); + font-size: 11px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-chip.muted { + border-style: dashed; + color: var(--color-text-tertiary, #a3a3aa); + opacity: 0.72; +} + +.workspace-row-disclosure { + align-items: start; +} + +.workspace-disclosure { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; +} + +.workspace-skills-list { + max-height: 24px; +} + +.workspace-disclosure.expanded { + grid-template-columns: minmax(0, 1fr); +} + +.workspace-skills-disclosure.expanded .workspace-skills-list { + max-height: none; + flex-wrap: wrap; + overflow: visible; +} + +.workspace-disclosure-toggle { + min-height: 24px; + padding: 3px 9px; + border: 1px solid color-mix(in srgb, var(--tool-accent) 28%, var(--tool-card-divider)); + border-radius: 9999px; + background: transparent; + color: var(--tool-accent); + cursor: pointer; + font: inherit; + font-size: var(--font-text-sm-size, 11px); + line-height: 1.25; + white-space: nowrap; +} + +.workspace-disclosure-toggle:hover { + background: color-mix(in srgb, var(--tool-accent) 10%, transparent); +} + +.workspace-disclosure-toggle:focus-visible { + outline: 2px solid color-mix(in srgb, var(--tool-accent) 72%, transparent); + outline-offset: 2px; +} + +.workspace-disclosure.expanded .workspace-disclosure-toggle { + justify-self: start; +} + +.workspace-value-list { + display: grid; + min-width: 0; + max-height: calc(1.45em); + overflow: hidden; + color: var(--color-text-secondary, #c7c7ce); + font-size: var(--font-text-sm-size, 12px); + line-height: 1.45; +} + +.workspace-value-list.mono { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); +} + +.workspace-value-item { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-disclosure.expanded .workspace-value-list { + max-height: none; + gap: 5px; + overflow: visible; +} + +.workspace-instruction-summary { + display: flex; + min-width: 0; + height: 24px; + align-items: center; + gap: 6px; + overflow: hidden; +} + +.workspace-instruction-summary-item { + display: inline-flex; + min-width: 0; + flex: 0 1 auto; + align-items: center; + gap: 5px; + overflow: hidden; + padding: 2px 7px 2px 5px; + border: 1px solid color-mix(in srgb, var(--tool-accent) 14%, var(--tool-card-divider)); + border-radius: 9999px; + background: color-mix(in srgb, var(--tool-accent) 6%, transparent); + color: var(--color-text-secondary, #c7c7ce); + font-size: var(--font-text-sm-size, 11px); + line-height: 1.25; +} + +.workspace-instruction-summary-item.available { + border-style: dashed; + color: var(--color-text-tertiary, #a3a3aa); +} + +.workspace-instruction-summary-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-instruction-status { + display: grid; + width: 18px; + height: 18px; + flex: 0 0 auto; + place-items: center; + border-radius: 5px; +} + +.workspace-instruction-status.loaded { + background: color-mix(in srgb, var(--color-success-text, #6fda83) 12%, transparent); + color: var(--color-success-text, #6fda83); +} + +.workspace-instruction-status.available { + background: color-mix(in srgb, var(--color-text-tertiary, #a3a3aa) 9%, transparent); + color: var(--color-text-tertiary, #a3a3aa); +} + +.workspace-instruction-status-svg { + width: 12px; + height: 12px; + stroke-width: 1.9; +} + +.workspace-instruction-list { + display: none; + min-width: 0; + overflow: hidden; + border: 1px solid var(--tool-card-divider); + border-radius: 9px; + background: color-mix(in srgb, var(--tool-card-body-bg) 88%, transparent); +} + +.workspace-instructions-disclosure.expanded .workspace-instruction-summary { + display: none; +} + +.workspace-instructions-disclosure.expanded .workspace-instruction-list { + display: grid; +} + +.workspace-instruction-item + .workspace-instruction-item { + border-top: 1px solid var(--tool-card-divider); +} + +.workspace-instruction-header { + display: grid; + width: 100%; + min-width: 0; + grid-template-columns: 22px minmax(0, 1fr) 18px; + align-items: center; + gap: 9px; + padding: 8px 10px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; +} + +.workspace-instruction-header.interactive { + cursor: pointer; +} + +.workspace-instruction-header.interactive:hover { + background: var(--tool-card-hover-bg); +} + +.workspace-instruction-header.interactive:focus-visible { + outline: 2px solid color-mix(in srgb, var(--tool-accent) 72%, transparent); + outline-offset: -2px; +} + +.workspace-instruction-text { + display: grid; + min-width: 0; + gap: 2px; +} + +.workspace-instruction-name { + min-width: 0; + overflow: hidden; + color: var(--color-text-primary, #f5f5f6); + font-size: var(--font-text-sm-size, 12px); + font-weight: 550; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-instruction-path { + min-width: 0; + overflow: hidden; + color: var(--color-text-tertiary, #a3a3aa); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 10px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-instruction-chevron { + display: grid; + width: 18px; + height: 18px; + place-items: center; + color: var(--color-text-tertiary, #a3a3aa); + transition: transform 140ms ease; +} + +.workspace-instruction-item.expanded .workspace-instruction-chevron { + transform: rotate(180deg); +} + +.workspace-instruction-chevron-svg { + width: 14px; + height: 14px; +} + +.workspace-instruction-preview { + max-height: 300px; + margin: 0; + overflow: auto; + padding: 10px 12px; + border-top: 1px solid var(--tool-card-divider); + background: color-mix(in srgb, var(--color-background-primary, #101114) 92%, transparent); + color: var(--color-text-secondary, #c7c7ce); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 11px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: break-word; +} + +.workspace-instruction-preview[hidden] { + display: none; +} + .review-header { - grid-template-columns: 54px minmax(0, 1fr) auto 24px; + grid-template-columns: 40px minmax(0, 1fr) auto 20px; } .review-title-group { @@ -230,15 +676,15 @@ body { .review-more { width: 100%; - min-height: 44px; - padding: 0 16px; + min-height: 40px; + padding: 0 12px; border: 0; border-top: 1px solid var(--tool-card-divider); background: transparent; - color: var(--color-text-secondary, #d6d6dc); + color: var(--color-text-tertiary, #a3a3aa); cursor: pointer; font: inherit; - font-size: var(--font-text-sm-size, 13px); + font-size: var(--font-text-sm-size, 12px); text-align: left; } @@ -250,7 +696,8 @@ body { .review-diff { display: grid; max-height: 520px; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; } .review-diff-files { @@ -271,11 +718,12 @@ body { .review-diff-file-header { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; + grid-template-columns: 22px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; width: 100%; - min-height: 50px; - padding: 0 16px; + min-height: 42px; + padding: 0 12px; border: 0; background: transparent; color: var(--color-text-primary, #f5f5f6); @@ -284,6 +732,41 @@ body { text-align: left; } +.review-file-kind { + display: grid; + width: 20px; + height: 20px; + align-self: center; + place-items: center; + border-radius: 6px; + background: color-mix(in srgb, var(--color-text-tertiary, #a3a3aa) 10%, transparent); + color: var(--color-text-tertiary, #a3a3aa); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 10px; + font-weight: 700; +} + +.review-file-kind.added { + background: color-mix(in srgb, var(--color-success-text, #6fda83) 12%, transparent); + color: var(--color-success-text, #6fda83); +} + +.review-file-kind.edited, +.review-file-kind.renamed, +.review-file-kind.renamed-edited { + background: color-mix(in srgb, var(--color-warning-text, #e6b566) 12%, transparent); + color: var(--color-warning-text, #e6b566); +} + +.review-file-kind.deleted { + background: color-mix(in srgb, var(--color-danger-text, #ee7676) 12%, transparent); + color: var(--color-danger-text, #ee7676); +} + +.review-single-file { + overflow: hidden; +} + .review-diff-file-header:hover { background: var(--tool-card-hover-bg); } @@ -291,12 +774,43 @@ body { .review-diff-file-name, .review-diff-file-stats { overflow: hidden; - font-size: var(--font-text-sm-size, 14px); + font-size: 13px; + line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } .review-diff-file-name { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); +} + +.review-diff-file-name.renamed { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + text-overflow: clip; +} + +.review-diff-file-path { + min-width: 0; + max-width: calc(50% - 10px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-diff-file-path.previous { + color: var(--color-text-tertiary, #a3a3aa); +} + +.review-diff-file-path.current { + color: var(--color-text-primary, #f5f5f6); +} + +.review-diff-file-arrow { + flex: 0 0 auto; + color: var(--color-text-tertiary, #a3a3aa); font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); } @@ -304,13 +818,13 @@ body { 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-size: var(--font-text-sm-size, 12px); font-variant-numeric: tabular-nums; } .status { - padding: 12px 14px; - font-size: var(--font-text-sm-size, 13px); + padding: 10px 12px; + font-size: var(--font-text-sm-size, 12px); } .status.muted { @@ -341,13 +855,13 @@ body { max-height: 420px; margin: 0; overflow: auto; - padding: 12px 14px; - color: var(--color-text-secondary, #d6d6dc); + 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; - word-break: break-word; + overflow-wrap: break-word; } .text-payload.bash { @@ -357,16 +871,16 @@ body { @media (max-width: 520px) { .tool-header { - grid-template-columns: 42px minmax(0, 1fr) auto 18px; - gap: 10px; - min-height: 68px; - padding: 10px 12px; + grid-template-columns: 36px minmax(0, 1fr) auto 18px; + gap: 9px; + min-height: 58px; + padding: 9px 10px; } .tool-icon { - width: 42px; - height: 42px; - border-radius: 10px; + width: 36px; + height: 36px; + border-radius: 9px; } .chevron { @@ -375,6 +889,24 @@ body { } .review-header { - grid-template-columns: 42px minmax(0, 1fr) auto 18px; + grid-template-columns: 36px minmax(0, 1fr) auto 18px; + } + + .workspace-row { + grid-template-columns: 22px minmax(0, 1fr); + gap: 2px 8px; + padding-block: 8px; + } + + .workspace-row-icon { + grid-row: 1 / span 2; + align-self: start; + } + + .workspace-row > .workspace-key, + .workspace-row > .workspace-value, + .workspace-row > .workspace-chip-list, + .workspace-row > .workspace-disclosure { + grid-column: 2; } } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index da32cd32..b5a69de0 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -8,6 +8,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { isEditTool, isExpandableCard, + isInitiallyExpandedCard, isPatchTool, isReadTool, isReviewTool, @@ -19,7 +20,7 @@ import { type ToolName, type ToolResultCard, } from "./card-types.js"; -import { renderIcon, toolIcons } from "./icons.js"; +import { renderIcon, toolIcons, type ToolIcon } from "./icons.js"; import { getToolDisplay, getToolHeaderSummary, @@ -47,6 +48,10 @@ let reviewFilesExpanded = false; let errorMessage: string | null = null; let currentPayload: MountedPayload | null = null; let currentPayloadContainer: HTMLElement | null = null; +let openWorkspaceInstructionKey: string | null = null; + +type WorkspaceDisclosureKey = "instructions" | "skills" | "agents"; +const expandedWorkspaceDisclosures = new Set(); const maybeAppRoot = document.querySelector("#app"); @@ -78,6 +83,8 @@ async function boot(): Promise { card = null; expanded = false; reviewFilesExpanded = false; + expandedWorkspaceDisclosures.clear(); + openWorkspaceInstructionKey = null; errorMessage = "No result card is available for this tool result."; render(); return; @@ -85,8 +92,10 @@ async function boot(): Promise { const nextCard = { ...structured, tool }; card = nextCard; - expanded = isReviewTool(tool) && isExpandableCard(nextCard); + expanded = isInitiallyExpandedCard(nextCard); reviewFilesExpanded = false; + expandedWorkspaceDisclosures.clear(); + openWorkspaceInstructionKey = null; errorMessage = null; render(); }; @@ -97,7 +106,9 @@ async function boot(): Promise { ...ctx, }; applyHostContext(); - renderPayloadIfNeeded(); + // Workspace details inherit host variables directly. Rebuilding their DOM on + // iframe resize would reset an in-progress disclosure interaction. + if (card?.tool !== "open_workspace") renderPayloadIfNeeded(); }; app.onteardown = async () => { @@ -161,7 +172,9 @@ function render(): void { const expandable = isExpandableCard(card); const main = element("main", { className: "shell" }); - const section = element("section", { className: `tool-card ${display.tone}` }); + const section = element("section", { + className: toolCardClassName(display), + }); const button = element("button", { className: "tool-header", type: "button", @@ -226,7 +239,7 @@ async function renderPayloadIfNeeded(): Promise { } if (card.tool === "open_workspace") { - renderPrePayload(target, workspacePayloadText(card), "open_workspace"); + renderWorkspacePayload(target, card); return; } @@ -324,7 +337,10 @@ function renderPrePayload( tool: string, ): void { unmountCurrentPayload(); - container.replaceChildren(element("pre", { className: `text-payload ${tool}`, text })); + container.replaceChildren(element("pre", { + className: `text-payload pretty-scrollbar ${tool}`, + text, + })); } function renderHeaderSummary(card: ToolResultCard): HTMLElement { @@ -356,7 +372,7 @@ function renderReviewCard(card: ToolResultCard, display: ToolDisplay): void { 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 section = element("section", { className: toolCardClassName(display) }); const header = element("button", { className: "tool-header review-header", type: "button", @@ -431,6 +447,12 @@ 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 setPayloadLoading(container: HTMLElement, loading: boolean): void { const header = container.previousElementSibling; const chevron = header?.querySelector(".chevron"); @@ -445,94 +467,503 @@ function setPayloadLoading(container: HTMLElement, loading: boolean): void { if (button) button.setAttribute("aria-busy", String(loading)); } -function workspacePayloadText(card: ToolResultCard): string { - const agentsFiles = card.agentsFiles ?? []; - const availableAgentsFiles = card.availableAgentsFiles ?? []; +function renderWorkspacePayload(container: HTMLElement, card: ToolResultCard): void { + unmountCurrentPayload(); + + const details = element("div", { + className: "workspace-details pretty-scrollbar", + }); + const rows = element("div", { className: "workspace-rows" }); + const worktree = card.worktree; + + if (worktree) { + const base = [ + worktree.baseRef, + worktree.baseSha?.slice(0, 8), + ].filter((value): value is string => Boolean(value)); + const baseLabel = base.join(" · ") || "Worktree"; + const baseContent = element("span", { className: "workspace-base-value" }); + baseContent.append(element("span", { + className: "workspace-value", + text: baseLabel, + title: baseLabel, + })); + + if (worktree.dirtySource) { + const warning = element("span", { + className: "workspace-base-warning", + title: "The source checkout had uncommitted changes when this worktree was created. Those changes are not included here.", + ariaLabel: "Source checkout changes are not included in this worktree", + }); + warning.append(renderIcon(toolIcons.warning, "workspace-base-warning-svg")); + baseContent.append(warning); + } + + appendWorkspaceRow(rows, "Base", baseContent, toolIcons.base); + } + + if (card.sourceRoot && card.sourceRoot !== card.root) { + appendWorkspaceTextRow( + rows, + "Source checkout", + card.sourceRoot, + toolIcons.sourceCheckout, + true, + ); + } + + appendWorkspaceInstructions( + rows, + card.agentsFiles ?? [], + card.availableAgentsFiles ?? [], + ); + const skills = card.skills ?? []; - const agentProviders = card.agentProviders ?? []; - const agents = card.agents ?? []; - const diagnostics = card.skillDiagnostics ?? []; - const lines = [ - card.workspaceId ? `Workspace: ${card.workspaceId}` : undefined, - card.root ? `Root: ${card.root}` : undefined, - card.mode ? `Mode: ${card.mode}` : undefined, - card.sourceRoot ? `Source root: ${card.sourceRoot}` : undefined, - card.worktree ? formatWorktree(card.worktree) : undefined, - skills.length > 0 - ? `Skills: ${skills.map((skill) => skill.name ?? skill.path ?? "unnamed").join(", ")}` - : "Skills: none", - agentProviders.length > 0 - ? `Agent providers: ${agentProviders.map(formatAgentProvider).join(", ")}` - : undefined, - agents.length > 0 - ? `Agents: ${agents.map(formatAgent).join(", ")}` - : undefined, - diagnostics.length > 0 - ? `Skill diagnostics: ${diagnostics.map(formatDiagnostic).join("; ")}` - : undefined, - availableAgentsFiles.length > 0 - ? `Nested instructions: ${availableAgentsFiles.map((file) => file.path ?? "unknown").join(", ")}` - : undefined, - agentsFiles.length > 0 - ? `\n${formatAgentsFilesForPayload(agentsFiles)}` - : "\nAGENTS.md: none loaded", - card.instruction ? `\nInstruction: ${card.instruction}` : undefined, - ].filter((line): line is string => typeof line === "string"); - - return lines.join("\n"); -} - -function formatWorktree(worktree: NonNullable): string { - const details = [ - worktree.baseRef ? `base ${worktree.baseRef}` : undefined, - worktree.baseSha ? `at ${worktree.baseSha.slice(0, 12)}` : undefined, - worktree.managed === true ? "managed" : undefined, - worktree.detached === true ? "detached" : undefined, - worktree.dirtySource === true ? "dirty source" : undefined, - ].filter((detail): detail is string => Boolean(detail)); - return `Worktree: ${worktree.path ?? "unknown"}${details.length > 0 ? ` (${details.join(", ")})` : ""}`; -} - -function formatAgentProvider( - provider: NonNullable[number], -): string { - const name = provider.name ?? "unknown"; - if (provider.available !== false) return name; - return provider.reason ? `${name} unavailable: ${provider.reason}` : `${name} unavailable`; -} - -function formatAgent(agent: NonNullable[number]): string { - const details = [ - agent.provider, - agent.model, - agent.thinking ? `thinking ${agent.thinking}` : undefined, - agent.providerAvailable === false - ? agent.providerUnavailableReason ?? "provider unavailable" + if (skills.length > 0) { + appendWorkspaceSkills(rows, skills); + } + + const providers = card.agentProviders ?? []; + if (providers.length > 0) { + const providerChips: WorkspaceChip[] = []; + for (const provider of providers) { + const unavailable = provider.available === false; + providerChips.push({ + label: provider.name ?? "Unknown provider", + tone: unavailable ? "muted" : undefined, + title: unavailable ? provider.reason ?? "Provider unavailable" : undefined, + }); + } + appendWorkspaceChipRow(rows, "Providers", providerChips, toolIcons.providers); + } + + const agentChips = (card.agents ?? []).map((agent) => { + const name = agent.name ?? "Unnamed agent"; + const unavailable = agent.providerAvailable === false; + return { + label: agent.provider ? `${name} · ${agent.provider}` : name, + tone: unavailable ? "muted" as const : undefined, + title: unavailable + ? agent.providerUnavailableReason ?? "Provider unavailable" + : undefined, + }; + }); + if (agentChips.length > 0) { + appendWorkspaceChipRow(rows, "Agents", agentChips, toolIcons.agents); + } + + if (rows.childElementCount > 0) details.append(rows); + + if (details.childElementCount === 0) { + details.append(element("div", { className: "status muted", text: "No workspace details available." })); + } + + container.replaceChildren(details); +} + +interface WorkspaceChip { + label: string; + title?: string; + tone?: "muted"; +} + +interface WorkspaceInstruction { + key: string; + path?: string; + label: string; + content?: string; + status: "loaded" | "available"; +} + +interface WorkspaceDisclosureRowOptions { + label: string; + icon: ToolIcon; + disclosureKey: WorkspaceDisclosureKey; + content: HTMLElement[]; + expandable?: boolean; + rowClassName?: string; + disclosureClassName?: string; + collapsedLabel?: string; + onCollapse?: () => void; +} + +function appendWorkspaceInstructions( + container: HTMLElement, + loadedFiles: NonNullable, + availableFiles: NonNullable, +): void { + const loaded: WorkspaceInstruction[] = []; + const loadedPaths = new Set(); + for (const [index, file] of loadedFiles.entries()) { + loaded.push({ + key: `loaded:${index}`, + path: file.path, + label: file.path ?? "Loaded instructions", + content: file.content, + status: "loaded", + }); + if (file.path) loadedPaths.add(file.path); + } + + const available: WorkspaceInstruction[] = []; + for (const [index, file] of availableFiles.entries()) { + if (file.path && loadedPaths.has(file.path)) continue; + available.push({ + key: `available:${index}`, + path: file.path, + label: file.path ?? "Nested instructions", + status: "available", + }); + } + const instructions: WorkspaceInstruction[] = [...loaded, ...available]; + + if (instructions.length === 0) return; + + const canPreview = loaded.some((file) => file.content !== undefined); + const canExpand = instructions.length > 1 || canPreview; + const summary = renderWorkspaceInstructionSummary(instructions); + const list = canExpand ? renderWorkspaceInstructionList(instructions) : undefined; + appendWorkspaceDisclosureRow(container, { + label: "Instructions", + icon: toolIcons.instructions, + disclosureKey: "instructions", + content: list ? [summary, list] : [summary], + expandable: canExpand, + rowClassName: "workspace-instructions-row", + disclosureClassName: "workspace-instructions-disclosure", + collapsedLabel: instructions.length === 1 ? "View" : undefined, + onCollapse: list + ? () => { + openWorkspaceInstructionKey = null; + syncWorkspaceInstructionPreviews(list); + } : undefined, - ].filter((detail): detail is string => Boolean(detail)); - return `${agent.name ?? "unnamed"}${details.length > 0 ? ` (${details.join(", ")})` : ""}`; + }); } -function formatDiagnostic(diagnostic: unknown): string { - if (typeof diagnostic === "string") return diagnostic; - if (diagnostic instanceof Error) return diagnostic.message; - try { - return JSON.stringify(diagnostic) ?? String(diagnostic); - } catch { - return String(diagnostic); +function renderWorkspaceInstructionSummary( + instructions: WorkspaceInstruction[], +): HTMLElement { + const summary = element("span", { className: "workspace-instruction-summary" }); + const basenameCounts = new Map(); + for (const instruction of instructions) { + const basename = workspacePathBasename(instruction.label); + basenameCounts.set(basename, (basenameCounts.get(basename) ?? 0) + 1); + } + + for (const instruction of instructions) { + const basename = workspacePathBasename(instruction.label); + const item = element("span", { + className: `workspace-instruction-summary-item ${instruction.status}`, + title: instruction.path + ? `${instructionStatusLabel(instruction.status)}: ${instruction.path}` + : instructionStatusLabel(instruction.status), + }); + item.append( + renderWorkspaceInstructionStatus(instruction.status), + element("span", { + className: "workspace-instruction-summary-name", + text: basenameCounts.get(basename) === 1 + ? basename + : workspaceCompactPath(instruction.label), + }), + ); + summary.append(item); } + return summary; } -function formatAgentsFilesForPayload( - agentsFiles: NonNullable, -): string { - return agentsFiles - .map((file) => { - const path = file.path ?? "AGENTS.md"; - const content = file.content?.trim(); - return content ? `${path}\n\n${content}` : `${path}\n\nNo content loaded.`; - }) - .join("\n\n"); +function renderWorkspaceInstructionList( + instructions: WorkspaceInstruction[], +): HTMLElement { + const list = element("span", { className: "workspace-instruction-list" }); + + for (const instruction of instructions) { + const item = element("span", { className: "workspace-instruction-item" }); + item.dataset.instructionKey = instruction.key; + const hasContent = instruction.status === "loaded" && instruction.content !== undefined; + const header = element(hasContent ? "button" : "span", { + className: `workspace-instruction-header${hasContent ? " interactive" : ""}`, + type: hasContent ? "button" : undefined, + ariaLabel: hasContent ? `View ${instruction.label}` : undefined, + ariaExpanded: hasContent ? "false" : undefined, + }); + const text = element("span", { className: "workspace-instruction-text" }); + const basename = workspacePathBasename(instruction.label); + text.append(element("span", { + className: "workspace-instruction-name", + text: basename, + })); + if (instruction.path && instruction.path !== basename) { + text.append(element("span", { + className: "workspace-instruction-path", + text: instruction.path, + title: instruction.path, + })); + } + + header.append( + renderWorkspaceInstructionStatus(instruction.status), + text, + ); + + if (hasContent) { + const chevron = element("span", { + className: "workspace-instruction-chevron", + ariaHidden: "true", + }); + chevron.append(renderIcon(toolIcons.chevronDown, "workspace-instruction-chevron-svg")); + header.append(chevron); + header.addEventListener("click", () => { + openWorkspaceInstructionKey = openWorkspaceInstructionKey === instruction.key + ? null + : instruction.key; + syncWorkspaceInstructionPreviews(list); + }); + + const preview = element("pre", { + className: "workspace-instruction-preview pretty-scrollbar", + text: instruction.content, + }); + preview.hidden = true; + item.append(header, preview); + } else { + item.append(header); + } + + list.append(item); + } + + syncWorkspaceInstructionPreviews(list); + return list; +} + +function syncWorkspaceInstructionPreviews(list: HTMLElement): void { + for (const item of list.querySelectorAll(".workspace-instruction-item")) { + const isOpen = item.dataset.instructionKey === openWorkspaceInstructionKey; + item.classList.toggle("expanded", isOpen); + const header = item.querySelector(".workspace-instruction-header.interactive"); + header?.setAttribute("aria-expanded", String(isOpen)); + const preview = item.querySelector(".workspace-instruction-preview"); + if (preview) preview.hidden = !isOpen; + } +} + +function renderWorkspaceInstructionStatus( + status: WorkspaceInstruction["status"], +): HTMLElement { + const label = instructionStatusLabel(status); + const wrapper = element("span", { + className: `workspace-instruction-status ${status}`, + title: label, + ariaLabel: label, + }); + wrapper.setAttribute("role", "img"); + wrapper.append(renderIcon( + status === "loaded" ? toolIcons.instructionLoaded : toolIcons.instructionAvailable, + "workspace-instruction-status-svg", + )); + return wrapper; +} + +function instructionStatusLabel(status: WorkspaceInstruction["status"]): string { + return status === "loaded" + ? "Loaded into the current workspace context" + : "Available for a nested directory"; +} + +function workspacePathBasename(path: string): string { + const parts = path.replaceAll("\\", "/").split("/").filter(Boolean); + return parts.at(-1) ?? path; +} + +function workspaceCompactPath(path: string): string { + const parts = path.replaceAll("\\", "/").split("/").filter(Boolean); + return parts.slice(-2).join("/") || path; +} + +function appendWorkspaceTextRow( + container: HTMLElement, + label: string, + value: string, + icon: ToolIcon, + mono = false, +): void { + const content = element("span", { + className: `workspace-value${mono ? " mono" : ""}`, + text: value, + title: value, + }); + appendWorkspaceRow(container, label, content, icon); +} + +function appendWorkspaceTextListRow( + container: HTMLElement, + label: string, + values: string[], + icon: ToolIcon, + disclosureKey: WorkspaceDisclosureKey, + mono = false, +): void { + if (values.length === 1) { + appendWorkspaceTextRow(container, label, values[0], icon, mono); + return; + } + + const list = element("span", { + className: `workspace-value-list${mono ? " mono" : ""}`, + }); + for (const value of values) { + list.append(element("span", { + className: "workspace-value-item", + text: value, + title: value, + })); + } + appendWorkspaceDisclosureRow(container, { + label, + icon, + disclosureKey, + content: [list], + }); +} + +function appendWorkspaceChipRow( + container: HTMLElement, + label: string, + chips: WorkspaceChip[], + icon: ToolIcon, +): void { + appendWorkspaceRow(container, label, renderWorkspaceChips(chips), icon); +} + +function appendWorkspaceRow( + container: HTMLElement, + label: string, + content: HTMLElement, + icon: ToolIcon, +): void { + const row = element("div", { className: "workspace-row" }); + row.append( + renderWorkspaceRowIcon(icon), + element("span", { className: "workspace-key", text: label }), + content, + ); + container.append(row); +} + +function appendWorkspaceSkills( + container: HTMLElement, + skills: NonNullable, +): void { + const skillChips = skills.map((skill) => ({ + label: skill.name ?? skill.path ?? "Unnamed skill", + title: skill.path, + })); + if (skillChips.length === 1) { + appendWorkspaceChipRow(container, "Skills", skillChips, toolIcons.skills); + return; + } + + const chipList = renderWorkspaceChips(skillChips); + chipList.classList.add("workspace-skills-list"); + appendWorkspaceDisclosureRow(container, { + label: "Skills", + icon: toolIcons.skills, + disclosureKey: "skills", + content: [chipList], + disclosureClassName: "workspace-skills-disclosure", + }); +} + +function appendWorkspaceDisclosureRow( + container: HTMLElement, + options: WorkspaceDisclosureRowOptions, +): void { + const expandable = options.expandable ?? true; + const initiallyExpanded = expandable && expandedWorkspaceDisclosures.has(options.disclosureKey); + const row = element("div", { + className: [ + "workspace-row", + "workspace-row-disclosure", + options.rowClassName, + initiallyExpanded ? "expanded" : undefined, + ].filter(Boolean).join(" "), + }); + const disclosure = element("span", { + className: [ + "workspace-disclosure", + options.disclosureClassName, + initiallyExpanded ? "expanded" : undefined, + ].filter(Boolean).join(" "), + }); + disclosure.append(...options.content); + + if (expandable) { + const toggle = renderWorkspaceDisclosureToggle( + initiallyExpanded, + (nextExpanded) => { + disclosure.classList.toggle("expanded", nextExpanded); + row.classList.toggle("expanded", nextExpanded); + if (nextExpanded) { + expandedWorkspaceDisclosures.add(options.disclosureKey); + } else { + expandedWorkspaceDisclosures.delete(options.disclosureKey); + options.onCollapse?.(); + } + }, + options.collapsedLabel, + ); + disclosure.append(toggle); + } + + row.append( + renderWorkspaceRowIcon(options.icon), + element("span", { className: "workspace-key", text: options.label }), + disclosure, + ); + container.append(row); +} + +function renderWorkspaceDisclosureToggle( + expanded: boolean, + onToggle: (expanded: boolean) => void, + collapsedLabel = "View all", +): HTMLButtonElement { + const toggle = element("button", { + className: "workspace-disclosure-toggle", + type: "button", + text: expanded ? "Show less" : collapsedLabel, + ariaExpanded: String(expanded), + }); + toggle.addEventListener("click", () => { + const nextExpanded = toggle.getAttribute("aria-expanded") !== "true"; + toggle.setAttribute("aria-expanded", String(nextExpanded)); + toggle.textContent = nextExpanded ? "Show less" : collapsedLabel; + onToggle(nextExpanded); + }); + return toggle; +} + +function renderWorkspaceRowIcon(icon: ToolIcon): HTMLElement { + const wrapper = element("span", { + className: "workspace-row-icon", + ariaHidden: "true", + }); + wrapper.append(renderIcon(icon, "workspace-row-icon-svg")); + return wrapper; +} + +function renderWorkspaceChips(chips: WorkspaceChip[]): HTMLElement { + const list = element("span", { className: "workspace-chip-list" }); + for (const chip of chips) { + list.append(element("span", { + className: `workspace-chip${chip.tone ? ` ${chip.tone}` : ""}`, + text: chip.label, + title: chip.title, + })); + } + return list; } function toolNameFromMeta(result: CallToolResult): ToolName | undefined { @@ -559,6 +990,7 @@ function element( type?: string; title?: string; ariaHidden?: string; + ariaLabel?: string; ariaExpanded?: string; disabled?: boolean; } = {}, @@ -569,6 +1001,7 @@ function element( if (options.type !== undefined && "type" in node) node.setAttribute("type", options.type); if (options.title !== undefined) node.title = options.title; if (options.ariaHidden !== undefined) node.setAttribute("aria-hidden", options.ariaHidden); + if (options.ariaLabel !== undefined) node.setAttribute("aria-label", options.ariaLabel); if (options.ariaExpanded !== undefined) node.setAttribute("aria-expanded", options.ariaExpanded); if (options.disabled !== undefined && "disabled" in node) { (node as HTMLButtonElement).disabled = options.disabled;