diff --git a/crates/fs-operation/src/lib.rs b/crates/fs-operation/src/lib.rs index f63a1ce3..53999081 100644 --- a/crates/fs-operation/src/lib.rs +++ b/crates/fs-operation/src/lib.rs @@ -279,4 +279,58 @@ mod tests { assert_eq!(grep.matched_files, 2); assert!(!grep.output.contains("c.txt")); } + + #[test] + fn grep_content_groups_lines_by_file_and_marks_matches() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("first.txt"), + "before\nneedle one\nafter\nomitted one\nomitted two\nbefore distant\nneedle distant\nafter distant\n", + ) + .unwrap(); + std::fs::write(temp.path().join("second.txt"), "needle two\n").unwrap(); + let root = temp.path().canonicalize().unwrap(); + let readable = RootAccess(root.clone()); + + let grep = run_grep( + &root, + root.clone(), + GrepRequest { + pattern: "needle".to_string(), + path: FsPath::root(), + glob: Some("*.txt".to_string()), + output_mode: GrepOutputMode::Content, + case_insensitive: false, + before_context: 1, + after_context: 1, + multiline: false, + file_type: None, + limit: 20, + offset: 0, + }, + &readable, + ) + .unwrap(); + + assert_eq!(grep.match_count, 3); + assert_eq!(grep.matched_files, 2); + assert_eq!( + grep.output, + concat!( + "first.txt\n", + " 1 │ before\n", + " > 2 │ needle one\n", + " 3 │ after\n", + " …\n", + " 6 │ before distant\n", + " > 7 │ needle distant\n", + " 8 │ after distant\n", + "\n", + "second.txt\n", + " > 1 │ needle two\n", + ) + ); + assert_eq!(grep.output.matches("first.txt").count(), 1); + assert_eq!(grep.output.matches("second.txt").count(), 1); + } } diff --git a/crates/fs-operation/src/search.rs b/crates/fs-operation/src/search.rs index 74a66d27..54420f6f 100644 --- a/crates/fs-operation/src/search.rs +++ b/crates/fs-operation/src/search.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; +use std::fmt::Write as _; use std::path::{Path, PathBuf}; use crate::FsAccessPolicy; @@ -57,20 +59,11 @@ impl GrepReport { } } GrepOutputMode::Content => { - for line in &self.lines { - let separator = if line.is_match { ':' } else { '-' }; - let path = logical_display(root, &line.path); - if self.show_line_numbers - && let Some(number) = line.line_number - { - output.push_str(&format!( - "{path}{separator}{number}{separator}{}\n", - line.text - )); - } else { - output.push_str(&format!("{path}{separator}{}\n", line.text)); - } - } + output.push_str(&render_content_lines( + root, + &self.lines, + self.show_line_numbers, + )); } } GrepResult { @@ -82,6 +75,48 @@ impl GrepReport { } } +fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: bool) -> String { + let mut grouped = BTreeMap::<&Path, Vec<&ContentLine>>::new(); + for line in lines { + grouped.entry(&line.path).or_default().push(line); + } + + let mut output = String::new(); + for (file_index, (path, file_lines)) in grouped.into_iter().enumerate() { + if file_index > 0 { + output.push('\n'); + } + let _ = writeln!(output, "{}", logical_display(root, path)); + + let number_width = file_lines + .iter() + .filter_map(|line| line.line_number) + .map(|number| number.to_string().len()) + .max() + .unwrap_or(1); + let mut previous_line_end = None; + for line in file_lines { + if let (Some(previous_end), Some(number)) = (previous_line_end, line.line_number) + && number > previous_end + { + let _ = writeln!(output, " …"); + } + + let marker = if line.is_match { '>' } else { ' ' }; + if show_line_numbers && let Some(number) = line.line_number { + let _ = writeln!(output, " {marker} {number:>number_width$} │ {}", line.text); + } else { + let _ = writeln!(output, " {marker} │ {}", line.text); + } + previous_line_end = line + .line_number + .map(|number| number + line.text.split('\n').count() as u64); + } + } + + output +} + fn logical_display(root: &Path, path: &Path) -> String { path.strip_prefix(root) .unwrap_or(path) diff --git a/crates/tools/src/grep.rs b/crates/tools/src/grep.rs index 598015b6..27c26ad0 100644 --- a/crates/tools/src/grep.rs +++ b/crates/tools/src/grep.rs @@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition { Arc::new(move || { let schema = schemars::schema_for!(GrepParams); let meta = ToolMeta::new("Grep") - .description("Search Workdir file contents with a regex. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.") + .description("Search Workdir file contents with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.") .input_schema(serde_json::to_value(schema).expect("Grep schema serialization")); let tool: Arc = Arc::new(GrepTool { session: session.clone(), diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index a68fd449..25ae28a6 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -9,9 +9,9 @@ "npm:@codemirror/view@6.43.8": "6.43.8", "npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0", "npm:@lezer/highlight@1.2.3": "1.2.3", - "npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7", - "npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7", - "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7", + "npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0", + "npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0", + "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0", "npm:clsx@2.1.1": "2.1.1", "npm:cookie@0.6.0": "0.6.0", "npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10", @@ -23,7 +23,8 @@ "npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3", "npm:svelte@5.45.6": "5.45.6", "npm:typescript@5.9.3": "5.9.3", - "npm:vite@7.2.7": "7.2.7" + "npm:vite@7.2.7": "7.2.7_yaml@2.9.0", + "npm:yaml@2.9.0": "2.9.0" }, "jsr": { "@std/assert@1.0.19": { @@ -433,13 +434,13 @@ "acorn" ] }, - "@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": { + "@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0": { "integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==", "dependencies": [ "@sveltejs/kit" ] }, - "@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": { + "@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": { "integrity": "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg==", "dependencies": [ "@standard-schema/spec", @@ -465,7 +466,7 @@ ], "bin": true }, - "@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_vite@7.2.7": { + "@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": { "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", "dependencies": [ "@sveltejs/vite-plugin-svelte", @@ -474,7 +475,7 @@ "vite" ] }, - "@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7": { + "@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": { "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dependencies": [ "@sveltejs/vite-plugin-svelte-inspector", @@ -966,7 +967,7 @@ "vfile-message" ] }, - "vite@7.2.7": { + "vite@7.2.7_yaml@2.9.0": { "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dependencies": [ "esbuild", @@ -974,14 +975,18 @@ "picomatch", "postcss", "rollup", - "tinyglobby" + "tinyglobby", + "yaml" ], "optionalDependencies": [ "fsevents" ], + "optionalPeers": [ + "yaml" + ], "bin": true }, - "vitefu@1.1.2_vite@7.2.7": { + "vitefu@1.1.2_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": { "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", "dependencies": [ "vite" @@ -993,6 +998,10 @@ "w3c-keyname@2.2.8": { "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" }, + "yaml@2.9.0": { + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "bin": true + }, "zimmerframe@1.1.4": { "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" }, @@ -1024,7 +1033,8 @@ "packageJson": { "dependencies": [ "npm:@fontsource/ibm-plex-mono@5.3.0", - "npm:gen-interface-jp@0.8.0" + "npm:gen-interface-jp@0.8.0", + "npm:yaml@2.9.0" ] } } diff --git a/web/workspace/package.json b/web/workspace/package.json index 63ae3ddf..21d7a9c3 100644 --- a/web/workspace/package.json +++ b/web/workspace/package.json @@ -5,6 +5,7 @@ "type": "module", "dependencies": { "@fontsource/ibm-plex-mono": "5.3.0", - "gen-interface-jp": "0.8.0" + "gen-interface-jp": "0.8.0", + "yaml": "2.9.0" } } diff --git a/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte b/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte index c2e3a640..a53e3fa3 100644 --- a/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte +++ b/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte @@ -8,6 +8,7 @@ }; let { item }: Props = $props(); + let detailOpen = $state(false); let nowMs = $state(Date.now()); $effect(() => { @@ -55,22 +56,20 @@ line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats'; } - function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } { - const [firstLine = '', ...rest] = line.body.split('\n'); - const [label, suffix = ''] = firstLine.split(' — ', 2); - return { - label, - suffix, - rest: rest.join('\n') - }; + function toolLabel(line: ConsoleLine): string { + return line.toolCallLabel ?? line.toolCall?.name ?? line.title; + } + + function toolStatus(line: ConsoleLine): string { + return line.toolStatus ?? line.toolCall?.state ?? ''; } function shouldRenderMarkdown(line: ConsoleLine): boolean { return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system'; } - function bodyTextAfterToolSummary(line: ConsoleLine): string { - return toolSummary(line).rest; + function toolBodyText(line: ConsoleLine): string { + return detailOpen ? (line.expandedBody ?? line.body) : line.body; } @@ -107,20 +106,27 @@ {:else if item.kind === 'tool'}
- {toolSummary(item).label} - - {toolSummary(item).suffix} + {toolLabel(item)} + {toolStatus(item)} + {#if item.detail} + + {/if}
{/if} {#if item.compaction} {:else if item.kind === 'tool'} - {#if bodyTextAfterToolSummary(item)} + {#if toolBodyText(item)}

{#if isBashTool(item)} - + {:else} - {bodyTextAfterToolSummary(item)} + {toolBodyText(item)} {/if}

{/if} @@ -147,11 +153,10 @@ {/each} {/if} - {#if item.detail} -
- detail + {#if item.detail && detailOpen} +

{item.detail}

-
+ {/if} @@ -306,48 +311,46 @@ .tool-summary { display: flex; align-items: baseline; - gap: 0; + gap: 0.5rem; color: var(--text-muted); font-size: 0.88rem; font-weight: 750; } .tool-label { - flex: 0 0 auto; - color: var(--tui-cyan); - white-space: nowrap; - } - - .tool-separator { - flex: 0 0 auto; - white-space: nowrap; - } - - .tool-suffix { flex: 1 1 auto; min-width: 0; - overflow-wrap: anywhere; + overflow: hidden; + color: var(--tui-cyan); + text-overflow: ellipsis; + white-space: nowrap; } - .tool-separator, - .tool-suffix { + .tool-status { + flex: 0 0 auto; color: var(--tui-dark-gray); + font-size: 0.72rem; + white-space: nowrap; } - .tool-state-error .tool-suffix { + .tool-state-error .tool-status { color: var(--tui-red); } - .tool-state-running .tool-suffix, - .tool-state-streaming_args .tool-suffix, - .tool-state-pending .tool-suffix { + .tool-state-running .tool-status, + .tool-state-streaming_args .tool-status, + .tool-state-pending .tool-status { color: var(--tui-yellow); } - .tool-state-done .tool-suffix { + .tool-state-done .tool-status { color: var(--tui-dark-gray); } + .console-line.error-line .tool-status { + color: var(--tui-red); + } + .message-heading { display: flex; align-items: center; @@ -412,13 +415,47 @@ color: var(--code); } + .tool-detail-button { + margin-inline-start: auto; + border: 1px solid var(--line); + border-radius: 0.35rem; + padding: 0.08rem 0.35rem; + background: var(--bg-raised); + color: var(--text-muted); + cursor: pointer; + font: inherit; + font-size: 0.68rem; + font-weight: 750; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; + } + + .console-line:hover .tool-detail-button, + .tool-detail-button:focus-visible, + .tool-detail-button[aria-expanded='true'] { + opacity: 1; + pointer-events: auto; + } + .message-detail { + margin-top: 0.35rem; + border-left: 2px solid var(--line); + padding-left: 0.6rem; color: var(--text-muted); font-size: 0.84rem; } - .message-detail summary { - cursor: pointer; - font-weight: 800; + .message-detail p { + margin: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; + } + + @media (hover: none) { + .tool-detail-button { + opacity: 1; + pointer-events: auto; + } } diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 98fb085a..39732416 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -313,26 +313,25 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () => !toolLines[0].streaming, "completed tool call should not remain streaming", ); - assert( - toolLines[0].body.includes("$ pwd"), - "Bash command should be summarized", - ); + assertEquals(toolLines[0].toolCallLabel, "Bash($ pwd)"); + assertEquals(toolLines[0].toolStatus, "done"); assert( toolLines[0].body.includes("/repo"), "tool result should be folded into the Call block", ); assert( toolLines[0].body.includes("line9"), - "Bash result preview should include the ninth output line", + "Bash preview should include the ninth output line", ); assert( !toolLines[0].body.includes("line10") && - !toolLines[0].body.includes("line12"), - "Bash result preview should be capped at ten display lines", + toolLines[0].body.includes("… +3 more lines"), + "Bash preview should retain its line cap", ); assert( - toolLines[0].body.includes("… +3 more lines"), - "Bash result preview should show omitted output count", + toolLines[0].expandedBody?.includes("line12") === true && + !toolLines[0].expandedBody?.includes("more lines"), + "Bash detail should show every returned output line", ); assert( toolLines[0].detail?.includes("id: call-1"), @@ -421,7 +420,8 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin ]); const [line] = projection.lines.filter((line) => line.kind === "tool"); - assert(line.body.includes("Bash — failed (exit 7)"), line.body); + assertEquals(line.toolCallLabel, "Bash($ long-command)"); + assertEquals(line.toolStatus, "failed (exit 7)"); assert(!line.body.includes("elapsed"), line.body); assert(!line.body.includes("stdout:"), line.body); assert(line.body.includes("ready\n"), line.body); @@ -463,7 +463,8 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => { const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]); const [line] = projection.lines.filter((line) => line.kind === "tool"); - assert(line.body.includes("Bash — running…"), line.body); + assertEquals(line.toolCallLabel, "Bash($ slow)"); + assertEquals(line.toolStatus, "running…"); assert(!line.body.includes("elapsed"), line.body); assert(!line.body.includes("stdout:"), line.body); assert(line.body.includes("[… earlier stdout omitted]\ntail\n"), line.body); @@ -474,7 +475,7 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => { assertEquals(line.streaming, true); }); -Deno.test("projectConsole caps default tool request and result previews", () => { +Deno.test("projectConsole caps default preview but keeps complete detail body", () => { const projection = projectConsole([ { eventId: "70", @@ -508,19 +509,100 @@ Deno.test("projectConsole caps default tool request and result previews", () => const [line] = projection.lines.filter((line) => line.kind === "tool"); assertEquals(line.title, "Call · CustomTool"); - assertEquals(line.body.split("\n").length, 7); - assert(line.body.includes("CustomTool — done"), "tool state should be shown"); + assertEquals(line.toolCallLabel, 'CustomTool("first":"one","second":"two","third":"three","fourth":"four")'); + assertEquals(line.toolStatus, "done"); + assertEquals(line.body.split("\n").length, 3); assert( - line.body.includes('"first": "one"'), - "request preview should be shown", + line.body.includes("out1") && line.body.includes("… +3 more lines"), + "normal display should retain the capped response preview", + ); + assert(!line.body.includes("first"), "request arguments should stay in the Call signature and detail"); + assert( + line.detail?.includes("arguments:\nfirst: one") === true && + line.detail?.includes("fourth: four") === true, + "detail metadata should render complete request arguments as YAML", + ); + assert( + line.expandedBody?.includes("out5") === true && + !line.expandedBody?.includes("more lines"), + "detail body should contain the complete result", ); - assert(line.body.includes("out1"), "result preview should be shown"); - assert(!line.body.includes("third"), "request preview should be capped"); - assert(!line.body.includes("out3"), "result preview should be capped"); - assert(line.body.includes("… +"), "overflow marker should be shown"); }); -Deno.test("projectConsole shows Grep query and caps result preview to five entries", () => { +Deno.test("projectConsole renders JSON tool responses as YAML", () => { + const projection = projectConsole([ + { + eventId: "json-call", + event: { + event: "tool_call_done", + data: { + id: "json-tool", + name: "CustomTool", + arguments: "{}", + }, + } satisfies Event, + }, + { + eventId: "json-result", + event: { + event: "tool_result", + data: { + id: "json-tool", + summary: "json completed", + output: JSON.stringify({ + status: "ok", + items: [{ id: 1 }, { id: 2 }], + }), + is_error: false, + }, + } satisfies Event, + }, + { + eventId: "invalid-json-call", + event: { + event: "tool_call_done", + data: { + id: "invalid-json-tool", + name: "CustomTool", + arguments: "{}", + }, + } satisfies Event, + }, + { + eventId: "invalid-json-result", + event: { + event: "tool_result", + data: { + id: "invalid-json-tool", + summary: "invalid json", + output: '{"status": broken}', + is_error: false, + }, + } satisfies Event, + }, + ]); + + const toolLines = projection.lines.filter((line) => line.kind === "tool"); + const jsonLine = toolLines.find((line) => line.id.includes("json-tool")); + const invalidLine = toolLines.find((line) => line.id.includes("invalid-json-tool")); + assert(jsonLine, "JSON tool line should be projected"); + assert(invalidLine, "invalid JSON tool line should be projected"); + assert( + jsonLine.expandedBody?.includes("status: ok") === true && + jsonLine.expandedBody?.includes(" - id: 2") === true, + "detail body should serialize parsed JSON as YAML", + ); + assert( + jsonLine.body.includes("more lines"), + "normal preview should cap the pretty-printed JSON", + ); + assert( + invalidLine.expandedBody?.includes('{"status": broken}') === true, + "invalid JSON-looking output should remain unchanged", + ); +}); + +Deno.test("projectConsole caps Grep preview but keeps complete detail body", () => { const projection = projectConsole([ { eventId: "72", @@ -549,17 +631,19 @@ Deno.test("projectConsole shows Grep query and caps result preview to five entri const [line] = projection.lines.filter((line) => line.kind === "tool"); assertEquals(line.title, "Call · Grep"); - assert( - line.body.includes("Grep — 6 matches"), - "Grep summary should be shown", - ); - assert(line.body.includes("query: needle"), "Grep query should be shown"); + assertEquals(line.toolCallLabel, "Grep(needle)"); + assertEquals(line.toolStatus, "done"); assert(line.body.includes("hit1"), "first result should be shown"); assert(line.body.includes("hit5"), "fifth result should be shown"); - assert(!line.body.includes("hit6"), "sixth result should be capped"); + assert(!line.body.includes("hit6"), "normal preview should retain its result cap"); assert( line.body.includes("… +1 more results"), - "overflow marker should be shown", + "preview should show the omitted result count", + ); + assert( + line.expandedBody?.includes("hit6") === true && + !line.expandedBody?.includes("more results"), + "detail body should show every Grep result", ); }); @@ -594,17 +678,15 @@ Deno.test("projectConsole keeps Grep error detail in the body", () => { const [line] = projection.lines.filter((line) => line.kind === "tool"); assertEquals(line.title, "Call · Grep"); - assert( - line.body.includes("Grep — Failed"), - "error suffix should stay short", - ); + assertEquals(line.toolCallLabel, "Grep(needle)"); + assertEquals(line.toolStatus, "error"); assert( line.body.includes(message), "error detail should remain visible in the body", ); assert( - !line.body.includes(`Grep — ${message}`), - "error detail should not be repeated in the suffix", + !line.toolCallLabel?.includes(message), + "error detail should not be repeated in the Call signature", ); }); @@ -879,9 +961,10 @@ Deno.test("projectConsole keeps streaming tool call updates in the same Call blo assertEquals(toolLines.length, 1); assertEquals(toolLines[0].title, "Call · Read"); assert(toolLines[0].streaming, "streaming tool call should remain streaming"); + assertEquals(toolLines[0].toolCallLabel, "Read(1 file)"); + assertEquals(toolLines[0].toolStatus, "reading…"); assert( - toolLines[0].body.includes("/tmp/a.md") && - toolLines[0].body.includes("Read — reading"), + toolLines[0].body.includes("/tmp/a.md"), "Read call should render aggregate progress and path without content", ); }); @@ -1018,10 +1101,8 @@ Deno.test("projectConsole aggregates Read calls without showing file content", ( const toolLines = projection.lines.filter((line) => line.kind === "tool"); assertEquals(toolLines.length, 1); assertEquals(toolLines[0].title, "Call · Read"); - assert( - toolLines[0].body.includes("Read — 2 files read"), - "aggregate count should be shown", - ); + assertEquals(toolLines[0].toolCallLabel, "Read(2 files)"); + assertEquals(toolLines[0].toolStatus, "done"); assert( toolLines[0].body.includes("/tmp/a.md"), "first path should be listed", @@ -1073,7 +1154,9 @@ Deno.test("projectConsole renders Edit calls with structured diff lines", () => const [line] = projection.lines.filter((line) => line.kind === "tool"); assertEquals(line.title, "Call · Edit"); - assert(line.body.includes("diff: -1 +2"), "diff summary should be shown"); + assertEquals(line.toolCallLabel, "Edit(/tmp/a.md)"); + assertEquals(line.toolStatus, "done"); + assertEquals(line.body, "ok"); assertEquals(line.diff?.map((row) => row.kind), [ "context", "remove", @@ -1242,13 +1325,13 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () => assertEquals(projection.status, "running"); assertEquals( projection.lines.map((line) => - `${line.kind}:${line.body}:${line.streaming}` + `${line.kind}:${line.toolCallLabel ? `${line.toolCallLabel}\n${line.body}` : line.body}:${line.streaming}` ), [ "user:seed user:false", "user:new user:false", "assistant:assistant reply:false", - "tool:Read — 1 file read\n /tmp/a.md:false", + "tool:Read(1 file)\n /tmp/a.md:false", "status:Compacting…:true", "in_flight:partial:true", ], @@ -1476,25 +1559,26 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd }, ]); - const bodies = projection.lines.filter((line) => line.kind === "tool").map(( - line, - ) => line.body); - assertEquals(bodies[0], "Read — 1 file read\n src/main.rs"); + const toolLines = projection.lines.filter((line) => line.kind === "tool"); + const bodies = toolLines.map((line) => line.body); + assertEquals(toolLines[0].toolCallLabel, "Read(1 file)"); + assertEquals(bodies[0], " src/main.rs"); assert( projection.lines[0].detail?.includes("from src/main.rs"), "Read summary detail path should be relative", ); assert( - bodies.some((body) => - body.includes("Write — out.txt") && body.includes("Wrote out.txt") + toolLines.some((line) => + line.toolCallLabel === "Write(out.txt)" && line.body.includes("Wrote out.txt") ), - "Write header and known result path should be relative", + "Write signature and known result path should be relative", ); assert( - bodies.some((body) => - body.includes("Edit — src/main.rs") && body.includes("Edited src/main.rs") + toolLines.some((line) => + line.toolCallLabel === "Edit(src/main.rs)" && + line.body.includes("Edited src/main.rs") ), - "Edit header and known result path should be relative", + "Edit signature and known result path should be relative", ); assert( bodies.some((body) => diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index 67a0f29e..d9e56953 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -11,6 +11,7 @@ import type { InternalWorkerSnapshot, Segment, } from "$lib/generated/protocol"; +import { stringify as stringifyYaml } from "yaml"; import { workspaceRoute } from "$lib/workspace/api/http"; import { applyRunActivityEvent, @@ -86,6 +87,9 @@ export type ConsoleLine = { kind: ConsoleLineKind; title: string; body: string; + expandedBody?: string; + toolCallLabel?: string; + toolStatus?: string; detail?: string; compaction?: ConsoleCompaction; diff?: ConsoleDiffLine[]; @@ -1376,7 +1380,10 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine { title: item.title.startsWith("Call · Tool result") ? item.title : `Call · ${toolCall.name}`, - body: renderToolCall(toolCall), + body: renderToolResponse(toolCall), + expandedBody: renderToolResponse(toolCall, true), + toolCallLabel: toolCallSignature(toolCall), + toolStatus: toolCallStatus(toolCall), detail: toolCallDetail(toolCall), diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined, streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal, @@ -1384,7 +1391,7 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine { }; } -function renderToolCall(toolCall: ToolCallView): string { +function renderToolResponse(toolCall: ToolCallView, expanded = false): string { switch (toolCall.name) { case "Read": return renderReadTool(toolCall); @@ -1395,14 +1402,54 @@ function renderToolCall(toolCall: ToolCallView): string { case "Glob": return renderSearchTool(toolCall); case "Grep": - return renderGrepTool(toolCall); + return renderGrepTool(toolCall, expanded); case "Bash": - return renderBashTool(toolCall); + return renderBashTool(toolCall, expanded); default: - return renderDefaultTool(toolCall); + return renderDefaultTool(toolCall, expanded); } } +function toolCallSignature(toolCall: ToolCallView): string { + const args = parsedArgs(toolCall); + switch (toolCall.name) { + case "Read": + return `Read(${readPath(toolCall)})`; + case "Write": + case "Edit": { + const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd); + return `${toolCall.name}(${path})`; + } + case "Glob": + return `Glob(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`; + case "Grep": + return `Grep(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`; + case "Bash": { + const command = stringField(args, "command"); + return `Bash(${command ? `$ ${singleLine(command)}` : genericCallArguments(toolCall)})`; + } + default: + return `${toolCall.name}(${genericCallArguments(toolCall)})`; + } +} + +function genericCallArguments(toolCall: ToolCallView): string { + const raw = toolCall.arguments ?? toolCall.argsStream; + if (!raw.trim()) return ""; + const parsed = parseJson(raw); + if (parsed === undefined) return singleLine(raw); + const serialized = JSON.stringify(parsed) ?? "null"; + return isRecord(parsed) ? serialized.slice(1, -1) : serialized; +} + +function singleLine(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function toolCallStatus(toolCall: ToolCallView): string { + return toolCall.name === "Bash" ? commandStateSuffix(toolCall) : stateSuffix(toolCall.state); +} + function aggregateReadToolLines(lines: ConsoleLine[]): ConsoleLine[] { const result: ConsoleLine[] = []; let index = 0; @@ -1434,9 +1481,6 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine { const paths = calls.map(readPath); const visiblePaths = inProgress ? paths.slice(-3) : paths; const body = compactLines([ - inProgress - ? `Read — reading (${count} file${plural(count)}…)` - : `Read — ${count} file${plural(count)} read`, visiblePaths.map((path) => ` ${path}`).join("\n"), inProgress && paths.length > visiblePaths.length ? ` … (${paths.length - visiblePaths.length} earlier)` @@ -1447,6 +1491,8 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine { kind: "tool", title: "Call · Read", body, + toolCallLabel: `Read(${count} file${plural(count)})`, + toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done", detail: calls.map(readDetail).join("\n\n"), eventId: group.at(-1)?.eventId, source: "event", @@ -1483,32 +1529,16 @@ function readDetail(toolCall: ToolCallView): string { ]); } -function renderReadTool(toolCall: ToolCallView): string { - return `Read — ${readPath(toolCall)} (${stateSuffix(toolCall.state)})`; +function renderReadTool(_toolCall: ToolCallView): string { + return ""; } function renderWriteTool(toolCall: ToolCallView): string { - const args = parsedArgs(toolCall); - const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd); - const content = stringField(args, "content"); - return compactLines([ - `Write — ${path} (${stateSuffix(toolCall.state)})`, - cappedSection(content, 5), - knownToolResultText(toolCall), - ]); + return knownToolResultText(toolCall) ?? ""; } function renderEditTool(toolCall: ToolCallView): string { - const args = parsedArgs(toolCall); - const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd); - const diff = editDiff(toolCall) ?? []; - const removes = diff.filter((line) => line.kind === "remove").length; - const adds = diff.filter((line) => line.kind === "add").length; - return compactLines([ - `Edit — ${path} (${stateSuffix(toolCall.state)})`, - diff.length > 0 ? `diff: -${removes} +${adds}` : undefined, - knownToolResultText(toolCall), - ]); + return knownToolResultText(toolCall) ?? ""; } function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined { @@ -1591,52 +1621,20 @@ function lcsTable(oldLines: string[], newLines: string[]): number[][] { } function renderSearchTool(toolCall: ToolCallView): string { - const summary = toolCall.summary?.trim(); - return compactLines([ - `${toolCall.name} — ${toolHeaderSuffix(toolCall, summary)}`, - knownToolResultText(toolCall), - ]); + return knownToolResultText(toolCall) ?? ""; } -function renderGrepTool(toolCall: ToolCallView): string { - const summary = toolCall.summary?.trim(); - return compactLines([ - `Grep — ${toolHeaderSuffix(toolCall, summary)}`, - grepQueryText(toolCall), - cappedResultSection(knownToolResultText(toolCall), 5), - ]); +function renderGrepTool(toolCall: ToolCallView, expanded: boolean): string { + const result = knownToolResultText(toolCall); + return expanded ? result ?? "" : cappedResultSection(result, 5) ?? ""; } -function toolHeaderSuffix( - toolCall: ToolCallView, - summary?: string, -): string { - if (toolCall.state === "error") { - return "Failed"; +function renderBashTool(toolCall: ToolCallView, expanded: boolean): string { + if (["done", "error"].includes(toolCall.state)) { + const result = resultText(toolCall); + return expanded ? result ?? "" : cappedDisplaySection(result, 10) ?? ""; } - return summary ? firstLine(summary) : stateSuffix(toolCall.state); -} - -function grepQueryText(toolCall: ToolCallView): string | undefined { - const args = parsedArgs(toolCall); - const pattern = stringField(args, "pattern"); - if (pattern) { - return `query: ${pattern}`; - } - const renderedArgs = argsText(toolCall); - return renderedArgs ? `query:\n${renderedArgs}` : undefined; -} - -function renderBashTool(toolCall: ToolCallView): string { - const args = parsedArgs(toolCall); - const command = stringField(args, "command"); - return compactLines([ - `Bash — ${commandStateSuffix(toolCall)}`, - command ? `$ ${command}` : argsText(toolCall), - ["done", "error"].includes(toolCall.state) - ? cappedDisplaySection(resultText(toolCall), 10) - : renderLiveCommandOutput(toolCall.command), - ]); + return renderLiveCommandOutput(toolCall.command) ?? ""; } function commandStateSuffix(toolCall: ToolCallView): string { @@ -1690,12 +1688,9 @@ function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined ]); } -function renderDefaultTool(toolCall: ToolCallView): string { - return compactLines([ - `${toolCall.name} — ${stateSuffix(toolCall.state)}`, - cappedDisplaySection(argsText(toolCall), 3), - cappedDisplaySection(resultText(toolCall), 3), - ]); +function renderDefaultTool(toolCall: ToolCallView, expanded: boolean): string { + const result = resultText(toolCall); + return expanded ? result ?? "" : cappedDisplaySection(result, 3) ?? ""; } function toolCallDetail(toolCall: ToolCallView): string { @@ -1713,10 +1708,30 @@ function toolCallDetail(toolCall: ToolCallView): string { } function resultText(toolCall: ToolCallView): string | undefined { - if (toolCall.output) { - return toolCall.output; + const text = toolCall.output || toolCall.summary; + return text ? formatJsonResponseAsYaml(text) : undefined; +} + +function formatJsonResponseAsYaml(text: string): string { + const trimmed = text.trim(); + if ( + !( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) + ) { + return text; + } + + try { + const parsed: unknown = JSON.parse(trimmed); + if (parsed === null || typeof parsed !== "object") { + return text; + } + return stringifyYaml(parsed).trimEnd(); + } catch { + return text; } - return toolCall.summary; } function knownToolResultText(toolCall: ToolCallView): string | undefined { @@ -1803,7 +1818,7 @@ function argsText(toolCall: ToolCallView): string { return ""; } const parsed = parseJson(raw); - return parsed === undefined ? raw : jsonPreview(parsed); + return parsed === undefined ? raw : stringifyYaml(parsed).trimEnd(); } function parsedArgs( @@ -1830,21 +1845,6 @@ function compactLines(lines: Array): string { return lines.filter((line): line is string => Boolean(line)).join("\n"); } -function cappedSection( - value: string | undefined, - cap: number, -): string | undefined { - if (!value) { - return undefined; - } - const lines = value.split(/\r?\n/); - const shown = lines.slice(0, cap); - if (lines.length > cap) { - shown.push(`… +${lines.length - cap} more lines`); - } - return shown.join("\n"); -} - function cappedDisplaySection( value: string | undefined, maxLines: number, diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 2cb0fffe..c1d2a31f 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -402,7 +402,7 @@ Deno.test("Worker Console renders markdown only for message rows", async () => { consoleLine.includes("item.kind === 'tool'") && consoleLine.includes("{#if isBashTool(item)}") && consoleLine.includes( - "", + "", ) && consoleLine.includes( ".console-line.tool-bash .console-plain-text", @@ -419,6 +419,30 @@ Deno.test("Worker Console renders markdown only for message rows", async () => { ); }); +Deno.test("Worker Console expands uncapped tool body from the hover detail action", async () => { + const consoleLine = await Deno.readTextFile( + new URL("./ConsoleLineItem.svelte", import.meta.url), + ); + + assert( + consoleLine.includes( + "return detailOpen ? (line.expandedBody ?? line.body) : line.body", + ) && + consoleLine.includes("line.toolCallLabel ?? line.toolCall?.name") && + consoleLine.includes('class={`tool-status') && + consoleLine.includes('class="tool-detail-button"') && + consoleLine.includes("aria-expanded={detailOpen}") && + consoleLine.includes("detailOpen = !detailOpen") && + consoleLine.includes("item.detail && detailOpen") && + consoleLine.includes('role="region"') && + consoleLine.includes(".console-line:hover .tool-detail-button") && + consoleLine.includes(".tool-detail-button:focus-visible") && + consoleLine.includes("@media (hover: none)") && + !consoleLine.includes('
'), + "Normal tool display should keep its preview while detail reveals the uncapped body and existing metadata", + ); +}); + Deno.test("Worker Console renders Edit diffs without preformatted template gaps", async () => { const consoleLine = await Deno.readTextFile( new URL("./ConsoleLineItem.svelte", import.meta.url),