From b0681d9541f2ad620e4dadcb90514e75f916bf22 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 16 May 2026 17:57:21 +0900 Subject: [PATCH] Normalize tool path display --- src/display/model.ts | 18 ++++-- src/display/paths.ts | 11 ++++ src/display/review.ts | 3 +- src/display/signature.ts | 1 + src/display/tool-view.ts | 21 +++--- src/display/types.ts | 1 + tests/display-model.test.ts | 40 +++++++++++- tests/view-renderers.test.ts | 121 +++++++++++++++++++++++++++++++++++ 8 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 src/display/paths.ts diff --git a/src/display/model.ts b/src/display/model.ts index 733a21c5..18748cbc 100644 --- a/src/display/model.ts +++ b/src/display/model.ts @@ -5,6 +5,7 @@ import type { TurnPlanStep } from "../generated/app-server/v2/TurnPlanStep"; import { inputToText, truncate } from "../utils"; import { taskStatusMarker } from "./labels"; import { agentDisplayItem } from "./agent"; +import { pathRelativeToRoot } from "./paths"; import { classifyExecutionState, executionState } from "./state"; import { bodyDetail, @@ -175,6 +176,7 @@ export function displayItemFromThreadItem(item: ThreadItem, turnId?: string): Di role: "tool", text: compactToolSummary(null, item.path), toolLabel: "imageView", + summaryPath: true, turnId, itemId: item.id, }; @@ -188,6 +190,7 @@ export function displayItemFromThreadItem(item: ThreadItem, turnId?: string): Di role: "tool", text: compactToolSummary(null, target, statusQualifier(item.status, failedStatusLabel(item.status))), toolLabel: "imageGeneration", + summaryPath: Boolean(item.savedPath), turnId, itemId: item.id, status: item.status, @@ -246,13 +249,13 @@ function commandTargetLabel(item: CommandExecutionItem): string { const action = representativeCommandAction(item.commandActions); if (action?.type === "search") { const query = commandActionValue(action.query); - const path = commandActionValue(action.path); + const path = commandActionPathLabel(action.path, item.cwd); if (query && path) return `${quoteInline(query)} in ${path}`; if (query) return quoteInline(query); if (path) return path; } if (action?.type === "read") return commandReadTargetLabel(action, item.cwd); - if (action?.type === "listFiles") return commandActionValue(action.path) ?? "workspace"; + if (action?.type === "listFiles") return commandActionPathLabel(action.path, item.cwd) ?? "workspace"; return unwrapShellLoginCommand(firstCommandLine(item.command)); } @@ -279,6 +282,11 @@ function commandActionValue(value: string | null): string | null { return trimmed ? trimmed : null; } +function commandActionPathLabel(value: string | null, cwd: string): string | null { + const path = commandActionValue(value); + return path ? pathRelativeToWorkspace(path, cwd) : null; +} + function commandReadTargetLabel(action: Extract, cwd: string): string { const path = commandActionValue(action.path); if (path) return pathRelativeToWorkspace(path, cwd); @@ -786,11 +794,7 @@ function autoReviewSummariesForTurns(items: DisplayItem[]): Map pathRelativeToRoot(path, root)); +} diff --git a/src/display/review.ts b/src/display/review.ts index 44654f55..e493a36e 100644 --- a/src/display/review.ts +++ b/src/display/review.ts @@ -3,6 +3,7 @@ import type { GuardianApprovalReviewAction } from "../generated/app-server/v2/Gu import type { ItemGuardianApprovalReviewCompletedNotification } from "../generated/app-server/v2/ItemGuardianApprovalReviewCompletedNotification"; import type { ItemGuardianApprovalReviewStartedNotification } from "../generated/app-server/v2/ItemGuardianApprovalReviewStartedNotification"; import type { DisplayItem } from "./types"; +import { pathsRelativeToRoot } from "./paths"; import { classifyExecutionState } from "./state"; type AutoReviewNotification = ItemGuardianApprovalReviewStartedNotification | ItemGuardianApprovalReviewCompletedNotification; @@ -101,7 +102,7 @@ function autoReviewActionRows(action: GuardianApprovalReviewAction): DisplayRow[ return [ { key: "action", value: "apply patch" }, { key: "cwd", value: action.cwd }, - { key: "files", value: action.files.length > 0 ? action.files.join("\n") : "(none)" }, + { key: "files", value: action.files.length > 0 ? pathsRelativeToRoot(action.files, action.cwd).join("\n") : "(none)" }, ]; } if (action.type === "networkAccess") { diff --git a/src/display/signature.ts b/src/display/signature.ts index d6237476..629aaa06 100644 --- a/src/display/signature.ts +++ b/src/display/signature.ts @@ -29,6 +29,7 @@ export function displayItemSignature(item: DisplayItem, context: DisplayItemSign executionState(item) ?? "", item.kind === "fileChange" ? JSON.stringify(item.changes) : "", item.kind === "fileChange" ? (context.workspaceRoot ?? "") : "", + item.kind === "tool" && item.summaryPath ? (context.workspaceRoot ?? "") : "", item.kind === "taskProgress" ? JSON.stringify({ explanation: item.explanation, steps: item.steps, status: item.status }) : "", item.kind === "agent" ? JSON.stringify({ diff --git a/src/display/tool-view.ts b/src/display/tool-view.ts index 2e238344..7cc81675 100644 --- a/src/display/tool-view.ts +++ b/src/display/tool-view.ts @@ -1,4 +1,5 @@ -import { executionState, pathRelativeToWorkspace } from "./model"; +import { executionState } from "./model"; +import { pathRelativeToRoot } from "./paths"; import type { ApprovalResultDisplayItem, CommandDisplayItem, @@ -37,7 +38,7 @@ export function toolResultView(item: ToolResultDisplayItem, workspaceRoot?: stri if (item.kind === "fileChange") return fileChangeToolView(item, workspaceRoot); if (item.kind === "approvalResult") return approvalToolView(item); if (item.kind === "reviewResult") return reviewToolView(item); - return genericToolView(item); + return genericToolView(item, workspaceRoot); } function commandToolView(item: CommandDisplayItem): ToolResultView { @@ -60,7 +61,7 @@ function commandToolView(item: CommandDisplayItem): ToolResultView { function fileChangeToolView(item: FileChangeDisplayItem, workspaceRoot?: string | null): ToolResultView { const displayChanges = item.changes.map((change) => ({ ...change, - displayPath: change.path && change.path !== "(unknown)" ? pathRelativeToWorkspace(change.path, workspaceRoot) : change.path, + displayPath: change.path && change.path !== "(unknown)" ? pathRelativeToRoot(change.path, workspaceRoot) : change.path, })); const details: ToolResultDetailSection[] = [ { @@ -87,11 +88,15 @@ function fileChangeToolView(item: FileChangeDisplayItem, workspaceRoot?: string ); } -function genericToolView(item: ToolDisplayItem): ToolResultView { - return toolView(item, `codex-panel__tool-item codex-panel__tool-item--${item.kind}`, item.toolLabel ?? item.kind, `${item.id}:details`, [ - ...(item.details ?? []).flatMap(detailSection), - ...outputSection(item.kind === "hook" ? "Hook output" : "Output", item.output), - ]); +function genericToolView(item: ToolDisplayItem, workspaceRoot?: string | null): ToolResultView { + return toolView( + item, + `codex-panel__tool-item codex-panel__tool-item--${item.kind}`, + item.toolLabel ?? item.kind, + `${item.id}:details`, + [...(item.details ?? []).flatMap(detailSection), ...outputSection(item.kind === "hook" ? "Hook output" : "Output", item.output)], + item.summaryPath ? pathRelativeToRoot(item.text, workspaceRoot) : item.text, + ); } function reviewToolView(item: ReviewResultDisplayItem): ToolResultView { diff --git a/src/display/types.ts b/src/display/types.ts index 67647b02..f84126b6 100644 --- a/src/display/types.ts +++ b/src/display/types.ts @@ -100,6 +100,7 @@ export interface ToolDisplayItem extends DisplayBase { kind: "tool" | "hook" | "reasoning"; role: "tool"; toolLabel?: string; + summaryPath?: boolean; status?: string; output?: string; details?: DisplayDetailSection[]; diff --git a/tests/display-model.test.ts b/tests/display-model.test.ts index 97319e34..2a74a0f8 100644 --- a/tests/display-model.test.ts +++ b/tests/display-model.test.ts @@ -310,6 +310,29 @@ describe("display model", () => { }); }); + it("summarizes parsed search paths relative to the command cwd", () => { + const item: ThreadItem = { + type: "commandExecution", + id: "cmd-1", + command: "rg target /vault/src/display", + cwd: "/vault", + processId: null, + source: "agent", + status: "completed", + commandActions: [{ type: "search", command: "rg", query: "target", path: "/vault/src/display" }], + aggregatedOutput: "search results", + exitCode: 0, + durationMs: 10, + }; + + expect(displayItemFromThreadItem(item, "t1")).toMatchObject({ + kind: "command", + actionLabel: "search", + text: "target in src/display", + state: "completed", + }); + }); + it("labels parsed file listing commands and summarizes their path", () => { const item: ThreadItem = { type: "commandExecution", @@ -501,6 +524,21 @@ describe("display model", () => { }); }); + it("marks image view summaries as path summaries", () => { + const item: ThreadItem = { + type: "imageView", + id: "image-1", + path: "/vault/project/assets/image.png", + }; + + expect(displayItemFromThreadItem(item, "t1")).toMatchObject({ + kind: "tool", + text: "/vault/project/assets/image.png", + toolLabel: "imageView", + summaryPath: true, + }); + }); + it("uses details as the summary when a tool target cannot be extracted", () => { const item: ThreadItem = { type: "dynamicToolCall", @@ -591,7 +629,7 @@ describe("display model", () => { rows: expect.arrayContaining([ { key: "action", value: "apply patch" }, { key: "cwd", value: "/vault" }, - { key: "files", value: "/vault/src/display/model.ts\n/vault/tests/display-model.test.ts" }, + { key: "files", value: "src/display/model.ts\ntests/display-model.test.ts" }, ]), }, ], diff --git a/tests/view-renderers.test.ts b/tests/view-renderers.test.ts index 5df04567..aa5e9a1c 100644 --- a/tests/view-renderers.test.ts +++ b/tests/view-renderers.test.ts @@ -126,6 +126,37 @@ describe("view renderers", () => { expect(displayItemSignature(streamed, context)).not.toBe(displayItemSignature(completed, context)); }); + it("invalidates path summary tool blocks when the workspace root changes", () => { + const item = { + id: "tool-path", + kind: "tool", + role: "tool", + text: "/vault/project/assets/image.png", + toolLabel: "imageView", + summaryPath: true, + } as const; + const baseContext = { busy: false, activeTurnId: null, displayItems: [item] }; + + expect(displayItemSignature(item, { ...baseContext, workspaceRoot: "/vault" })).not.toBe( + displayItemSignature(item, { ...baseContext, workspaceRoot: "/vault/project" }), + ); + }); + + it("does not invalidate generic tool blocks when only the workspace root changes", () => { + const item = { + id: "tool", + kind: "tool", + role: "tool", + text: "/vault/project", + toolLabel: "example.tool", + } as const; + const baseContext = { busy: false, activeTurnId: null, displayItems: [item] }; + + expect(displayItemSignature(item, { ...baseContext, workspaceRoot: "/vault" })).toBe( + displayItemSignature(item, { ...baseContext, workspaceRoot: "/vault/project" }), + ); + }); + it("renders review result items as compact auto-review tool rows", () => { const block = messageRenderBlocks({ activeThreadId: "thread", @@ -709,6 +740,96 @@ describe("view renderers", () => { expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("https://example.com"); }); + it("renders path summary tools relative to the workspace root", () => { + const block = messageRenderBlocks({ + activeThreadId: "thread", + activeTurnId: "turn", + historyCursor: null, + loadingHistory: false, + busy: true, + workspaceRoot: "/vault/project", + displayItems: [ + { + id: "tool-path", + kind: "tool", + role: "tool", + text: "/vault/project/assets/image.png", + toolLabel: "imageView", + summaryPath: true, + turnId: "turn", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = block.render(); + + expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("assets/image.png"); + expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBe("assets/image.png"); + }); + + it("keeps path summary tools absolute outside the workspace root", () => { + const block = messageRenderBlocks({ + activeThreadId: "thread", + activeTurnId: "turn", + historyCursor: null, + loadingHistory: false, + busy: true, + workspaceRoot: "/vault/project", + displayItems: [ + { + id: "tool-path", + kind: "tool", + role: "tool", + text: "/tmp/image.png", + toolLabel: "imageView", + summaryPath: true, + turnId: "turn", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = block.render(); + + expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("/tmp/image.png"); + }); + + it("does not treat generic tool summaries as paths without an explicit marker", () => { + const block = messageRenderBlocks({ + activeThreadId: "thread", + activeTurnId: "turn", + historyCursor: null, + loadingHistory: false, + busy: true, + workspaceRoot: "/vault/project", + displayItems: [ + { + id: "tool-path-like", + kind: "tool", + role: "tool", + text: "/vault/project", + toolLabel: "example.tool", + turnId: "turn", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + })[0]; + + const element = block.render(); + + expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("/vault/project"); + }); + it("renders hook metadata as rows inside one details block", () => { const block = messageRenderBlocks({ activeThreadId: "thread",