Skip to content

Commit 1964e4f

Browse files
njtNat Torkingtonclaudewesm
authored
Show full tool call content with show-more toggle (#594)
## Summary - Remove the 200-character truncation on tool call parameter values in `generateFallbackContent()` so the full content is always available in the DOM - Add a Bash-specific display branch that shows `description` and `command` fields with full content (also handles Gemini's `run_command` tool name) - Cap all fallback content at 200 lines (instead of 200 chars) to prevent DOM bloat on very large outputs - Add a show-more/less toggle in `ToolBlock.svelte` that previews the first 20 lines with a button to expand — works for both the `content` and `fallbackContent` paths - Auto-expand content fully when a search match highlights inside a truncated block - Deduplicate show-more button markup between content paths ## Motivation Tool calls with long content (e.g. Bash heredocs writing files, large Write operations) were truncated at 200 characters in the UI with no way to see the full content, even though the raw JSONL and SQLite DB store everything. This made it impossible to inspect what an agent actually did. ## Test plan - [x] 8 new tests in `tool-params.test.ts` covering Bash fallback, `run_command` routing, no-truncation of long values, and line-count capping - [x] 3 new tests in `ToolBlock.test.ts` covering show-more button visibility, expand/collapse behavior, and short-content no-button case - [x] All 66 tests in tool-params and 36 tests in ToolBlock pass - [x] Manual verification: dev server shows full tool call content with working show-more toggle 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Nat Torkington <gnat@Nats-MacBook-Pro.local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com>
1 parent 17365a3 commit 1964e4f

4 files changed

Lines changed: 305 additions & 18 deletions

File tree

frontend/src/lib/components/content/ToolBlock.svelte

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,13 @@
5555
if (!hq.trim()) {
5656
searchExpandedInput = false;
5757
searchExpandedOutput = false;
58+
contentFullyExpanded = false;
5859
prevQuery = hq;
5960
return;
6061
}
6162
const q = hq.toLowerCase();
6263
const inputText = (
63-
taskPrompt ?? content ?? fallbackContent ?? ""
64+
taskPrompt ?? fallbackContent ?? content ?? ""
6465
).toLowerCase();
6566
const historyText = (
6667
toolCall?.result_events?.map((event) => event.content).join("\n\n") ?? ""
@@ -71,6 +72,7 @@
7172
searchExpandedInput = inputText.includes(q);
7273
searchExpandedOutput = outputText.includes(q);
7374
searchExpandedHistory = historyText.includes(q);
75+
if (searchExpandedInput) contentFullyExpanded = true;
7476
if (hq !== prevQuery) {
7577
userOverride = false;
7678
userOutputOverride = false;
@@ -274,6 +276,24 @@
274276
let subagentSessionId = $derived(
275277
isTask ? toolCall?.subagent_session_id ?? null : null,
276278
);
279+
const CONTENT_PREVIEW_LINES = 20;
280+
let contentFullyExpanded: boolean = $state(false);
281+
282+
let displayContent = $derived.by(() => {
283+
const raw = fallbackContent ?? content ?? "";
284+
if (!raw) return { text: "", isLong: false };
285+
const lines = raw.split("\n");
286+
const isLong = lines.length > CONTENT_PREVIEW_LINES;
287+
if (isLong && !contentFullyExpanded) {
288+
return {
289+
text: lines.slice(0, CONTENT_PREVIEW_LINES).join("\n"),
290+
isLong: true,
291+
totalLines: lines.length,
292+
};
293+
}
294+
return { text: raw, isLong, totalLines: lines.length };
295+
});
296+
277297
let isDiff = $derived.by(() => {
278298
const text = fallbackContent ?? content ?? "";
279299
return text.startsWith("--- a/") || text.startsWith("@@");
@@ -294,6 +314,7 @@
294314
if (sel && sel.toString().length > 0) return;
295315
userCollapsed = !userCollapsed;
296316
userOverride = true;
317+
if (userCollapsed) contentFullyExpanded = false;
297318
}}
298319
>
299320
<span class="tool-chevron" class:open={!collapsed}>
@@ -328,16 +349,25 @@
328349
{/if}
329350
{#if taskPrompt}
330351
<pre class="tool-content" use:applyHighlight={{ q: highlightQuery, current: isCurrentHighlight, content: taskPrompt }}>{@html escapeHTML(taskPrompt)}</pre>
331-
{:else if content}
332-
<pre class="tool-content" use:applyHighlight={{ q: highlightQuery, current: isCurrentHighlight, content }}>{@html escapeHTML(content)}</pre>
333352
{:else if fallbackContent && isDiff}
334353
<div class="diff-view">
335354
{#each diffLines as line}
336355
<div class="diff-line {line.startsWith('@@') ? 'diff-hunk' : line.startsWith('+') ? 'diff-add' : line.startsWith('-') ? 'diff-del' : 'diff-ctx'}">{line}</div>
337356
{/each}
338357
</div>
339-
{:else if fallbackContent}
340-
<pre class="tool-content" use:applyHighlight={{ q: highlightQuery, current: isCurrentHighlight, content: fallbackContent }}>{@html escapeHTML(fallbackContent)}</pre>
358+
{:else if displayContent.text}
359+
<pre class="tool-content" use:applyHighlight={{ q: highlightQuery, current: isCurrentHighlight, content: displayContent.text }}>{@html escapeHTML(displayContent.text)}</pre>
360+
{#if displayContent.isLong}
361+
<button
362+
class="show-more-btn"
363+
onclick={(e) => {
364+
e.stopPropagation();
365+
contentFullyExpanded = !contentFullyExpanded;
366+
}}
367+
>
368+
{contentFullyExpanded ? "show less" : `show all ${displayContent.totalLines} lines`}
369+
</button>
370+
{/if}
341371
{/if}
342372
{#if toolCall?.result_content}
343373
<button
@@ -525,6 +555,22 @@
525555
font-weight: 500;
526556
}
527557
558+
.show-more-btn {
559+
display: block;
560+
width: 100%;
561+
padding: 4px 14px;
562+
font-family: var(--font-mono);
563+
font-size: 11px;
564+
color: var(--accent-blue, #58a6ff);
565+
text-align: left;
566+
border-top: 1px solid var(--border-muted);
567+
transition: background 0.1s;
568+
}
569+
570+
.show-more-btn:hover {
571+
background: var(--bg-surface-hover);
572+
}
573+
528574
.tool-content {
529575
padding: 8px 14px 10px;
530576
font-family: var(--font-mono);

frontend/src/lib/components/content/ToolBlock.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,111 @@ describe("ToolBlock fallback content", () => {
373373
});
374374
});
375375

376+
describe("ToolBlock show-more for long content", () => {
377+
let component: ReturnType<typeof mount>;
378+
379+
afterEach(() => {
380+
if (component) unmount(component);
381+
document.body.innerHTML = "";
382+
});
383+
384+
it("shows 'show all' button for long Bash fallback content", async () => {
385+
const longCommand = Array.from({ length: 30 }, (_, i) => `echo line${i}`).join("\n");
386+
const toolCall: ToolCall = {
387+
tool_name: "Bash",
388+
category: "Bash",
389+
input_json: JSON.stringify({ command: longCommand }),
390+
};
391+
component = mount(ToolBlock, {
392+
target: document.body,
393+
props: { content: "", toolCall },
394+
});
395+
await tick();
396+
397+
document.querySelector<HTMLButtonElement>(".tool-header")!.click();
398+
await tick();
399+
400+
const showMoreBtn = document.querySelector(".show-more-btn");
401+
expect(showMoreBtn).not.toBeNull();
402+
expect(showMoreBtn!.textContent).toContain("show all");
403+
});
404+
405+
it("expands to full content when 'show all' is clicked", async () => {
406+
const longCommand = Array.from({ length: 30 }, (_, i) => `echo line${i}`).join("\n");
407+
const toolCall: ToolCall = {
408+
tool_name: "Bash",
409+
category: "Bash",
410+
input_json: JSON.stringify({ command: longCommand }),
411+
};
412+
component = mount(ToolBlock, {
413+
target: document.body,
414+
props: { content: "", toolCall },
415+
});
416+
await tick();
417+
418+
document.querySelector<HTMLButtonElement>(".tool-header")!.click();
419+
await tick();
420+
421+
const contentBefore = document.querySelector(".tool-content")!.textContent!;
422+
expect(contentBefore).not.toContain("echo line29");
423+
424+
document.querySelector<HTMLButtonElement>(".show-more-btn")!.click();
425+
await tick();
426+
427+
const contentAfter = document.querySelector(".tool-content")!.textContent!;
428+
expect(contentAfter).toContain("echo line29");
429+
430+
const showMoreBtn = document.querySelector(".show-more-btn");
431+
expect(showMoreBtn!.textContent).toContain("show less");
432+
});
433+
434+
it("does not show 'show all' button for short content", async () => {
435+
const toolCall: ToolCall = {
436+
tool_name: "Bash",
437+
category: "Bash",
438+
input_json: JSON.stringify({ command: "npm test" }),
439+
};
440+
component = mount(ToolBlock, {
441+
target: document.body,
442+
props: { content: "", toolCall },
443+
});
444+
await tick();
445+
446+
document.querySelector<HTMLButtonElement>(".tool-header")!.click();
447+
await tick();
448+
449+
expect(document.querySelector(".show-more-btn")).toBeNull();
450+
});
451+
452+
it("auto-expands hidden Bash fallback content on search match", async () => {
453+
const longCommand = Array.from(
454+
{ length: 30 },
455+
(_, i) => `echo hidden-line-${i}`,
456+
).join("\n");
457+
const toolCall: ToolCall = {
458+
tool_name: "Bash",
459+
category: "Bash",
460+
input_json: JSON.stringify({ command: longCommand }),
461+
};
462+
component = mount(ToolBlock, {
463+
target: document.body,
464+
props: {
465+
content: "",
466+
toolCall,
467+
highlightQuery: "hidden-line-29",
468+
},
469+
});
470+
await tick();
471+
472+
const toolContent = document.querySelector(".tool-content");
473+
expect(toolContent).not.toBeNull();
474+
expect(toolContent!.textContent).toContain("hidden-line-29");
475+
expect(document.querySelector(".show-more-btn")!.textContent).toContain(
476+
"show less",
477+
);
478+
});
479+
});
480+
376481
describe("ToolBlock collapsed preview", () => {
377482
let component: ReturnType<typeof mount>;
378483

frontend/src/lib/utils/tool-params.test.ts

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,107 @@ describe("generateFallbackContent", () => {
539539
});
540540
});
541541

542+
describe("generateFallbackContent - Bash", () => {
543+
it("shows full command for Bash tool", () => {
544+
const result = generateFallbackContent("Bash", {
545+
command: "npm test",
546+
});
547+
expect(result).toBe("command: npm test");
548+
});
549+
550+
it("shows description and command for Bash tool", () => {
551+
const result = generateFallbackContent("Bash", {
552+
command: "npm test",
553+
description: "Run test suite",
554+
});
555+
expect(result).toBe("description: Run test suite\ncommand: npm test");
556+
});
557+
558+
it("shows remaining visible params after Bash command", () => {
559+
const result = generateFallbackContent("Bash", {
560+
command: "npm test",
561+
description: "Run test suite",
562+
workdir: "/repo/frontend",
563+
timeout: 120000,
564+
env: { CI: "true" },
565+
agent__intent: "internal note",
566+
});
567+
expect(result).toBe(
568+
[
569+
"description: Run test suite",
570+
"command: npm test",
571+
"workdir: /repo/frontend",
572+
"timeout: 120000",
573+
'env: {"CI":"true"}',
574+
].join("\n"),
575+
);
576+
});
577+
578+
it("shows full multiline heredoc command without truncation", () => {
579+
const heredoc = [
580+
'cd /project && PYTHONIOENCODING=utf-8 python - <<\'PY\'',
581+
"import json",
582+
'F="data.jsonl"',
583+
"rows=[json.loads(l) for l in open(F,encoding='utf-8')]",
584+
"for r in rows:",
585+
" print(r)",
586+
"PY",
587+
].join("\n");
588+
const result = generateFallbackContent("Bash", {
589+
command: heredoc,
590+
description: "Inspect data",
591+
});
592+
expect(result).toContain("import json");
593+
expect(result).toContain("for r in rows:");
594+
expect(result).not.toContain("…");
595+
});
596+
597+
it("caps Bash commands at MAX_DIFF_LINES", () => {
598+
const longCommand = Array.from({ length: 250 }, (_, i) => `echo ${i}`).join("\n");
599+
const result = generateFallbackContent("Bash", {
600+
command: longCommand,
601+
})!;
602+
expect(result).toContain("lines total)");
603+
const lines = result.split("\n");
604+
expect(lines.length).toBeLessThanOrEqual(202);
605+
});
606+
607+
it("handles run_command tool name (Gemini)", () => {
608+
const result = generateFallbackContent("run_command", {
609+
command: "go test ./...",
610+
});
611+
expect(result).toBe("command: go test ./...");
612+
});
613+
614+
it("uses cmd param for codex exec_command", () => {
615+
const result = generateFallbackContent("Bash", {
616+
cmd: "ls -la /tmp",
617+
});
618+
expect(result).toBe("command: ls -la /tmp");
619+
});
620+
});
621+
622+
describe("generateFallbackContent - generic no-truncation", () => {
623+
it("shows full long values without character truncation", () => {
624+
const longValue = "x".repeat(500);
625+
const result = generateFallbackContent("CustomTool", {
626+
data: longValue,
627+
})!;
628+
expect(result).toBe(`data: ${longValue}`);
629+
expect(result).not.toContain("…");
630+
});
631+
632+
it("caps generic content at line count", () => {
633+
const longValue = Array.from({ length: 250 }, (_, i) => `line ${i}`).join("\n");
634+
const result = generateFallbackContent("CustomTool", {
635+
data: longValue,
636+
})!;
637+
expect(result).toContain("lines total)");
638+
const lines = result.split("\n");
639+
expect(lines.length).toBeLessThanOrEqual(202);
640+
});
641+
});
642+
542643
describe("generateFallbackContent - agent__intent filtering", () => {
543644
it("does not include agent__intent in generic key-value output", () => {
544645
const result = generateFallbackContent("CustomTool", {
@@ -554,9 +655,8 @@ describe("generateFallbackContent - agent__intent filtering", () => {
554655
command: "npm test",
555656
agent__intent: "Running test suite",
556657
});
557-
// Bash has no special handler for command; falls to generic loop
558-
// agent__intent must not appear; command may appear
559658
expect(result).not.toContain("agent__intent");
659+
expect(result).toBe("command: npm test");
560660
});
561661

562662
it("does not include agent__intent for read tool (generic path)", () => {

0 commit comments

Comments
 (0)