Normalize tool path display

This commit is contained in:
murashit 2026-05-16 17:57:21 +09:00
parent a46d9e99f0
commit b0681d9541
8 changed files with 199 additions and 17 deletions

View file

@ -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<CommandAction, { type: "read" }>, cwd: string): string {
const path = commandActionValue(action.path);
if (path) return pathRelativeToWorkspace(path, cwd);
@ -786,11 +794,7 @@ function autoReviewSummariesForTurns(items: DisplayItem[]): Map<string, string[]
}
export function pathRelativeToWorkspace(path: string, workspaceRoot?: string | null): string {
const normalizedPath = path.replace(/\\/g, "/").replace(/^\.\//, "");
const root = workspaceRoot?.replace(/\\/g, "/").replace(/\/+$/, "");
if (!root) return normalizedPath;
if (normalizedPath === root) return ".";
return normalizedPath.startsWith(`${root}/`) ? normalizedPath.slice(root.length + 1) : normalizedPath;
return pathRelativeToRoot(path, workspaceRoot);
}
function turnActivitySummary(items: DisplayItem[]): string {

11
src/display/paths.ts Normal file
View file

@ -0,0 +1,11 @@
export function pathRelativeToRoot(path: string, root?: string | null): string {
const normalizedPath = path.replace(/\\/g, "/").replace(/^\.\//, "");
const normalizedRoot = root?.replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalizedRoot) return normalizedPath;
if (normalizedPath === normalizedRoot) return ".";
return normalizedPath.startsWith(`${normalizedRoot}/`) ? normalizedPath.slice(normalizedRoot.length + 1) : normalizedPath;
}
export function pathsRelativeToRoot(paths: string[], root?: string | null): string[] {
return paths.map((path) => pathRelativeToRoot(path, root));
}

View file

@ -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") {

View file

@ -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({

View file

@ -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 {

View file

@ -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[];

View file

@ -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" },
]),
},
],

View file

@ -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",