Skip to content

Commit d8ba17d

Browse files
committed
fix(chat): improve tool-call display for read and search results
- render read tool results as highlighted file content in ToolCallGroup - simplify search command titles to show scope path only - wrap accordion title in span for proper tooltip overflow handling - map OpenCode `search` tool to canonical search kind with locations - fix image tool detection to avoid false positives on search patterns
1 parent cf6f737 commit d8ba17d

9 files changed

Lines changed: 173 additions & 40 deletions

File tree

src/renderer/components/thread/ChatPane/parts/items/ChatItemAccordion.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,14 @@ export function ChatItemAccordion({
115115
);
116116

117117
const titleNode = (
118-
<Tooltip delay={300} isDisabled={!isOverflowing || !titleString}>
119-
<Tooltip.Trigger className="min-w-0 flex-1">{titleContent}</Tooltip.Trigger>
120-
<Tooltip.Content placement="top" className="max-w-[80vw] break-all">
121-
{titleString}
122-
</Tooltip.Content>
123-
</Tooltip>
118+
<span className="min-w-0 flex-1">
119+
<Tooltip delay={300} isDisabled={!isOverflowing || !titleString}>
120+
<Tooltip.Trigger className="block min-w-0 w-full">{titleContent}</Tooltip.Trigger>
121+
<Tooltip.Content placement="top" className="max-w-[80vw] break-all">
122+
{titleString}
123+
</Tooltip.Content>
124+
</Tooltip>
125+
</span>
124126
);
125127

126128
if (!hasBody) {

src/renderer/components/thread/ChatPane/parts/items/ToolCallGroup.test.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,21 @@ describe("ToolCallGroup", () => {
164164
expect(screen.queryByText("result")).not.toBeInTheDocument();
165165
});
166166

167+
it("renders read tool-call results as highlighted file content", async () => {
168+
const threadId = "thread-1";
169+
const item = makeReadToolItem("tool-read");
170+
seedThread(threadId, [item]);
171+
172+
renderToolCallGroup(threadId, [item.id]);
173+
fireEvent.click(screen.getByText("source.ts"));
174+
175+
await waitFor(() => {
176+
expect(document.body).toHaveTextContent(/export const value = 1/);
177+
});
178+
expect(screen.queryByText("args")).not.toBeInTheDocument();
179+
expect(screen.queryByText("result")).not.toBeInTheDocument();
180+
});
181+
167182
it("renders changes-array creates as highlighted file content", async () => {
168183
const threadId = "thread-1";
169184
const item = makeChangesArrayFileChangeItem("file-changes-array-create", "create");
@@ -200,9 +215,8 @@ describe("ToolCallGroup", () => {
200215
);
201216

202217
expect(document.body).toHaveTextContent("View 1:24: src/supervisor/runtime.test.ts");
203-
expect(
204-
screen.getByText('Search files: "vitest.mjs" in node_modules/.pnpm'),
205-
).toBeInTheDocument();
218+
expect(document.body).toHaveTextContent("Search:");
219+
expect(document.body).toHaveTextContent(/node_modules.*pnpm/);
206220
expect(screen.getByText("Git: git diff -- src/supervisor/runtime.ts")).toBeInTheDocument();
207221
expect(screen.getByText("Check: pnpm run test")).toBeInTheDocument();
208222
expect(screen.getByText("Install packages: pnpm install")).toBeInTheDocument();
@@ -378,6 +392,24 @@ function makeApplyPatchToolItem(id: string): RuntimeChatItem {
378392
};
379393
}
380394

395+
function makeReadToolItem(id: string): RuntimeChatItem {
396+
return {
397+
id,
398+
type: "tool_call",
399+
state: "completed",
400+
payload: {
401+
name: "src/source.ts",
402+
title: "src/source.ts",
403+
kind: "read",
404+
locations: [{ path: "src/source.ts" }],
405+
args: { filePath: "src/source.ts" },
406+
result: "export const value = 1;\n",
407+
status: "success",
408+
},
409+
streams: {},
410+
};
411+
}
412+
381413
function makeFileChangeItem(
382414
id: string,
383415
diffSummary: { added: number; removed: number } = { added: 1, removed: 1 },

src/renderer/components/thread/ChatPane/parts/items/ToolCallGroup.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -360,10 +360,21 @@ function getToolCallRow(item: RuntimeChatItem, isExpanded: boolean): InlineRow |
360360
const diffPart = isEditLikeToolPayload(payload) ? extractAcpDiffResultPart(payload) : undefined;
361361
const diffText = diffPart?.text || undefined;
362362
const lazyReadPath = pickLazyReadPath(payload);
363+
const readText = payload.kind === "read" && !lazyReadPath ? extractAcpResultText(payload) : "";
364+
const readPath =
365+
payload.kind === "read"
366+
? display.parts?.filePath
367+
? display.parts.path
368+
: pickFirstLocationPath(payload)
369+
: undefined;
363370
const hasDetails =
364-
payload.args !== undefined || payload.result !== undefined || !!diffText || !!lazyReadPath;
371+
payload.args !== undefined ||
372+
payload.result !== undefined ||
373+
!!diffText ||
374+
!!lazyReadPath ||
375+
readText.length > 0;
365376
const sections: ToolCallSection[] =
366-
isExpanded && hasDetails && !diffText && !lazyReadPath
377+
isExpanded && hasDetails && !diffText && !lazyReadPath && readText.length === 0
367378
? [
368379
{ label: "args", part: extractAcpArgsPart(payload) },
369380
{ label: "result", part: extractAcpResultPart(payload) },
@@ -387,9 +398,10 @@ function getToolCallRow(item: RuntimeChatItem, isExpanded: boolean): InlineRow |
387398
rightLabelClassName: isError ? "text-danger" : "text-[color:var(--muted)]",
388399
hasDetails,
389400
sections,
390-
bodyText: isExpanded ? diffText : undefined,
401+
bodyText: isExpanded ? (diffText ?? (readText.length > 0 ? readText : undefined)) : undefined,
402+
bodyLanguage: readPath ? detectLanguageFromPath(readPath) : undefined,
391403
bodyKind: diffText ? "diff" : "text",
392-
bodyFilePath: display.parts?.filePath ? display.parts.path : undefined,
404+
bodyFilePath: display.parts?.filePath ? display.parts.path : readPath,
393405
fetchPath: lazyReadPath,
394406
};
395407
}
@@ -406,6 +418,10 @@ function pickLazyReadPath(payload: ToolCallPayload): string | undefined {
406418
return payload.locations?.find((location) => location.path.length > 0)?.path;
407419
}
408420

421+
function pickFirstLocationPath(payload: ToolCallPayload): string | undefined {
422+
return payload.locations?.find((location) => location.path.length > 0)?.path;
423+
}
424+
409425
function isEditLikeToolPayload(payload: ToolCallPayload): boolean {
410426
switch (payload.kind) {
411427
case "edit":

src/renderer/components/thread/ChatPane/parts/items/commandSummary.test.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,22 +96,25 @@ describe("humanIntentTitle", () => {
9696

9797
it("describes ripgrep commands as searches", () => {
9898
const full = `/bin/zsh -lc 'rg -n "agent status|AgentStatus" src/main src/supervisor src/shared -S'`;
99-
expect(humanIntentTitle(full)).toBe(
100-
'Search: "agent status|AgentStatus" in src/main src/supervisor src/shared',
101-
);
99+
expect(humanIntentTitle(full)).toBe("Search: src/main src/supervisor src/shared");
102100
expect(commandIntentDisplay(full).kind).toBe("search");
101+
expect(commandIntentDisplay(full).parts).toEqual({
102+
prefix: "Search: ",
103+
path: "src/main src/supervisor src/shared",
104+
});
103105
});
104106

105107
it("describes plain grep commands as searches", () => {
106108
const full = `grep -n "toastId" src/renderer/notifications.ts`;
107-
expect(humanIntentTitle(full)).toBe('Search: "toastId" in src/renderer/notifications.ts');
109+
expect(humanIntentTitle(full)).toBe("Search: src/renderer/notifications.ts");
108110
expect(commandIntentDisplay(full).kind).toBe("search");
109111
});
110112

111113
it("describes recursive grep with multiple paths as a search", () => {
112114
const full = `grep -rn "filteredCommands" src/renderer src/shared`;
113115
expect(commandIntentDisplay(full)).toEqual({
114-
title: 'Search: "filteredCommands" in src/renderer src/shared',
116+
title: "Search: src/renderer src/shared",
117+
parts: { prefix: "Search: ", path: "src/renderer src/shared" },
115118
kind: "search",
116119
});
117120
});
@@ -123,7 +126,7 @@ describe("humanIntentTitle", () => {
123126

124127
it("handles grep -e PATTERN form", () => {
125128
const full = `grep -rn -e "needle" src`;
126-
expect(humanIntentTitle(full)).toBe('Search: "needle" in src');
129+
expect(humanIntentTitle(full)).toBe("Search: src");
127130
});
128131

129132
it("describes cat piped through sed as viewed lines", () => {
@@ -148,8 +151,12 @@ describe("humanIntentTitle", () => {
148151

149152
it("describes find commands as searches", () => {
150153
const full = `find node_modules/.pnpm -maxdepth 4 -type f -name 'vitest.mjs' | sed -n '1,80p'`;
151-
expect(humanIntentTitle(full)).toBe('Search files: "vitest.mjs" in node_modules/.pnpm');
154+
expect(humanIntentTitle(full)).toBe("Search: node_modules/.pnpm");
152155
expect(commandIntentDisplay(full).kind).toBe("search");
156+
expect(commandIntentDisplay(full).parts).toEqual({
157+
prefix: "Search: ",
158+
path: "node_modules/.pnpm",
159+
});
153160
});
154161

155162
it("describes directory listings and package manager commands", () => {

src/renderer/components/thread/ChatPane/parts/items/commandSummary.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,20 +181,26 @@ function intentFromSummarizedCommand(t: string): CommandIntentDisplay | null {
181181

182182
const grepLike = parseGrepLikeSearch(trimmed);
183183
if (grepLike) {
184+
if (grepLike.scope) {
185+
const prefix = "Search: ";
186+
return {
187+
title: `${prefix}${grepLike.scope}`,
188+
parts: { prefix, path: grepLike.scope },
189+
kind: "search",
190+
};
191+
}
184192
return {
185-
title: grepLike.scope
186-
? `Search: "${grepLike.pattern}" in ${grepLike.scope}`
187-
: `Search: "${grepLike.pattern}"`,
193+
title: `Search: "${grepLike.pattern}"`,
188194
kind: "search",
189195
};
190196
}
191197

192198
const findSearch = parseFindSearch(trimmed);
193199
if (findSearch) {
200+
const prefix = "Search: ";
194201
return {
195-
title: findSearch.pattern
196-
? `Search files: "${findSearch.pattern}" in ${findSearch.scope}`
197-
: `Search files: ${findSearch.scope}`,
202+
title: `${prefix}${findSearch.scope}`,
203+
parts: { prefix, path: findSearch.scope },
198204
kind: "search",
199205
};
200206
}

src/renderer/components/thread/ChatPane/parts/items/toolDisplay.test.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { Eye, Pencil, SearchCode, Terminal } from "lucide-react";
2+
import { Eye, ImageIcon, Pencil, SearchCode, Terminal } from "lucide-react";
33
import type { ToolCallPayload } from "@/shared/contracts";
44
import { deriveToolDisplay, isSubAgentTool } from "./toolDisplay";
55

@@ -78,7 +78,7 @@ describe("deriveToolDisplay", () => {
7878
expect(display.Icon).toBe(Pencil);
7979
});
8080

81-
it("labels ACP local search tools with the query and scope", () => {
81+
it("labels ACP local search tools with only the scope", () => {
8282
const display = deriveToolDisplay(
8383
makePayload({
8484
name: "'attachment' in src/renderer/**",
@@ -88,14 +88,45 @@ describe("deriveToolDisplay", () => {
8888
}),
8989
);
9090

91-
expect(display.title).toBe('Search: "attachment" in src/renderer/**');
91+
expect(display.title).toBe("Search: src/renderer/**");
9292
expect(display.parts).toEqual({
93-
prefix: 'Search: "attachment" in ',
93+
prefix: "Search: ",
9494
path: "src/renderer/**",
9595
});
9696
expect(display.Icon).toBe(SearchCode);
9797
});
9898

99+
it("does not treat search patterns containing image as image tools", () => {
100+
const display = deriveToolDisplay(
101+
makePayload({
102+
name: String.raw`\"document\"|\"image\"|\"other\" in src`,
103+
title: String.raw`\"document\"|\"image\"|\"other\" in src`,
104+
kind: "search",
105+
args: { pattern: String.raw`\"document\"|\"image\"|\"other\"`, path: "src" },
106+
}),
107+
);
108+
109+
expect(display.title).toBe("Search: src");
110+
expect(display.parts).toEqual({
111+
prefix: "Search: ",
112+
path: "src",
113+
});
114+
expect(display.Icon).toBe(SearchCode);
115+
});
116+
117+
it("still labels explicit image tools as image rows", () => {
118+
const display = deriveToolDisplay(
119+
makePayload({
120+
name: "ViewImage",
121+
args: { path: "screen.png" },
122+
}),
123+
);
124+
125+
expect(display.title).toBe("Image: screen.png");
126+
expect(display.parts).toEqual({ prefix: "Image: ", path: "screen.png", filePath: true });
127+
expect(display.Icon).toBe(ImageIcon);
128+
});
129+
99130
it("normalizes Claude raw read tools to view file displays", () => {
100131
const display = deriveToolDisplay(
101132
makePayload({

src/renderer/components/thread/ChatPane/parts/items/toolDisplay.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,13 @@ function mapClaudeRawTool(
133133
return { title: titleWithValue("Task output", args, "id"), Icon: Terminal };
134134
case "TaskStop":
135135
return { title: titleWithValue("Stop task", args, "id"), Icon: Trash2 };
136+
case "ViewImage":
137+
case "Image":
138+
return withPath("Image", args, ["path", "file_path", "image_path", "source"], ImageIcon, {
139+
filePath: true,
140+
});
136141
default:
137-
return name.toLowerCase().includes("image")
138-
? withPath("Image", args, ["path", "file_path", "image_path", "source"], ImageIcon, {
139-
filePath: true,
140-
})
141-
: null;
142+
return null;
142143
}
143144
}
144145

@@ -392,7 +393,7 @@ function formatAcpSearchDisplay(
392393
const scope = readScope(args) ?? locationPath;
393394
const searchTerm = query ?? pattern;
394395
if (searchTerm && scope) {
395-
const prefix = `Search: "${searchTerm}" in `;
396+
const prefix = "Search: ";
396397
return {
397398
title: `${prefix}${scope}`,
398399
Icon: SearchCode,

src/supervisor/agents/opencode/sdkCanonicalMapping.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,42 @@ describe("sdkCanonicalMapping — tool parts", () => {
552552
});
553553
});
554554

555+
it("maps OpenCode local search tools as canonical search tool calls", () => {
556+
const state = createOpenCodeMapperState("thread-1");
557+
const args = {
558+
pattern: String.raw`\"document\"|\"image\"|\"other\"`,
559+
include: "*.ts",
560+
path: "/repo/src",
561+
};
562+
const events = mapOpenCodeEvent(
563+
toolPartUpdatedEvent({
564+
id: "prt_search",
565+
sessionID: "ses_test",
566+
messageID: "msg_1",
567+
type: "tool",
568+
tool: "search",
569+
callID: "call_search",
570+
state: {
571+
status: "running",
572+
input: args,
573+
time: { start: 0 },
574+
},
575+
}),
576+
state,
577+
);
578+
579+
expect(events.find((e) => e.type === "item.started")).toMatchObject({
580+
itemType: "tool_call",
581+
payload: {
582+
kind: "search",
583+
title: `"${args.pattern}" in /repo/src`,
584+
locations: [{ path: "/repo/src" }],
585+
args,
586+
status: "running",
587+
},
588+
});
589+
});
590+
555591
it("maps OpenCode task tools to sub-agent tool calls", () => {
556592
const state = createOpenCodeMapperState("thread-1");
557593
const events = mapOpenCodeEvent(

src/supervisor/agents/opencode/sdkCanonicalMapping.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ function classifyToolItemType(toolName: string): CanonicalItemType {
7878
if (/(^|[_-])(create|edit|write|patch|multiedit)($|[_-])/.test(n)) {
7979
return "file_change";
8080
}
81-
if (/(^|[_-])(webfetch|websearch|search)($|[_-])/.test(n)) {
81+
if (/(^|[_-])(webfetch|websearch)($|[_-])/.test(n)) {
8282
return "web_search";
8383
}
8484
return "tool_call";
@@ -172,6 +172,7 @@ function openCodeToolKind(
172172
return "read";
173173
case "glob":
174174
case "grep":
175+
case "search":
175176
return "search";
176177
case "webfetch":
177178
return "fetch";
@@ -199,11 +200,12 @@ function openCodeToolTitle(
199200
return readOpenCodePath(input) ?? "Read";
200201
case "glob":
201202
return readStringField(input, "pattern", "glob") ?? "Glob";
203+
case "search":
202204
case "grep": {
203205
const pattern = readStringField(input, "pattern", "query", "needle");
204-
const scope = readStringField(input, "path", "glob");
206+
const scope = readStringField(input, "path", "glob", "include");
205207
if (pattern && scope) return `"${pattern}" in ${scope}`;
206-
return pattern ?? "Grep";
208+
return pattern ?? (normalizeToolName(toolName) === "search" ? "Search" : "Grep");
207209
}
208210
case "webfetch":
209211
return readStringField(input, "url") ?? "Fetch";
@@ -225,7 +227,7 @@ function openCodeToolLocations(
225227
const path = readOpenCodePath(input);
226228
return path ? [{ path }] : undefined;
227229
}
228-
if (n === "grep") {
230+
if (n === "grep" || n === "search") {
229231
const path = readStringField(input, "path");
230232
return path ? [{ path }] : undefined;
231233
}

0 commit comments

Comments
 (0)