feat: merge web console tool call presentation

This commit is contained in:
2026-08-29 13:21:35 +09:00
9 changed files with 464 additions and 219 deletions
+54
View File
@@ -279,4 +279,58 @@ mod tests {
assert_eq!(grep.matched_files, 2); assert_eq!(grep.matched_files, 2);
assert!(!grep.output.contains("c.txt")); 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);
}
} }
+49 -14
View File
@@ -1,3 +1,5 @@
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::FsAccessPolicy; use crate::FsAccessPolicy;
@@ -57,20 +59,11 @@ impl GrepReport {
} }
} }
GrepOutputMode::Content => { GrepOutputMode::Content => {
for line in &self.lines { output.push_str(&render_content_lines(
let separator = if line.is_match { ':' } else { '-' }; root,
let path = logical_display(root, &line.path); &self.lines,
if self.show_line_numbers 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));
}
}
} }
} }
GrepResult { 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 { fn logical_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root) path.strip_prefix(root)
.unwrap_or(path) .unwrap_or(path)
+1 -1
View File
@@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
Arc::new(move || { Arc::new(move || {
let schema = schemars::schema_for!(GrepParams); let schema = schemars::schema_for!(GrepParams);
let meta = ToolMeta::new("Grep") 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")); .input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
let tool: Arc<dyn Tool> = Arc::new(GrepTool { let tool: Arc<dyn Tool> = Arc::new(GrepTool {
session: session.clone(), session: session.clone(),
+22 -12
View File
@@ -9,9 +9,9 @@
"npm:@codemirror/view@6.43.8": "6.43.8", "npm:@codemirror/view@6.43.8": "6.43.8",
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0", "npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
"npm:@lezer/highlight@1.2.3": "1.2.3", "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/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_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___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", "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:clsx@2.1.1": "2.1.1",
"npm:cookie@0.6.0": "0.6.0", "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", "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-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
"npm:svelte@5.45.6": "5.45.6", "npm:svelte@5.45.6": "5.45.6",
"npm:typescript@5.9.3": "5.9.3", "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": { "jsr": {
"@std/assert@1.0.19": { "@std/assert@1.0.19": {
@@ -433,13 +434,13 @@
"acorn" "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==", "integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==",
"dependencies": [ "dependencies": [
"@sveltejs/kit" "@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==", "integrity": "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg==",
"dependencies": [ "dependencies": [
"@standard-schema/spec", "@standard-schema/spec",
@@ -465,7 +466,7 @@
], ],
"bin": true "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==", "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
"dependencies": [ "dependencies": [
"@sveltejs/vite-plugin-svelte", "@sveltejs/vite-plugin-svelte",
@@ -474,7 +475,7 @@
"vite" "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==", "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"dependencies": [ "dependencies": [
"@sveltejs/vite-plugin-svelte-inspector", "@sveltejs/vite-plugin-svelte-inspector",
@@ -966,7 +967,7 @@
"vfile-message" "vfile-message"
] ]
}, },
"vite@7.2.7": { "vite@7.2.7_yaml@2.9.0": {
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dependencies": [ "dependencies": [
"esbuild", "esbuild",
@@ -974,14 +975,18 @@
"picomatch", "picomatch",
"postcss", "postcss",
"rollup", "rollup",
"tinyglobby" "tinyglobby",
"yaml"
], ],
"optionalDependencies": [ "optionalDependencies": [
"fsevents" "fsevents"
], ],
"optionalPeers": [
"yaml"
],
"bin": true "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==", "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==",
"dependencies": [ "dependencies": [
"vite" "vite"
@@ -993,6 +998,10 @@
"w3c-keyname@2.2.8": { "w3c-keyname@2.2.8": {
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
}, },
"yaml@2.9.0": {
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"bin": true
},
"zimmerframe@1.1.4": { "zimmerframe@1.1.4": {
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
}, },
@@ -1024,7 +1033,8 @@
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [
"npm:@fontsource/ibm-plex-mono@5.3.0", "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"
] ]
} }
} }
+2 -1
View File
@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@fontsource/ibm-plex-mono": "5.3.0", "@fontsource/ibm-plex-mono": "5.3.0",
"gen-interface-jp": "0.8.0" "gen-interface-jp": "0.8.0",
"yaml": "2.9.0"
} }
} }
@@ -8,6 +8,7 @@
}; };
let { item }: Props = $props(); let { item }: Props = $props();
let detailOpen = $state(false);
let nowMs = $state(Date.now()); let nowMs = $state(Date.now());
$effect(() => { $effect(() => {
@@ -55,22 +56,20 @@
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats'; line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
} }
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } { function toolLabel(line: ConsoleLine): string {
const [firstLine = '', ...rest] = line.body.split('\n'); return line.toolCallLabel ?? line.toolCall?.name ?? line.title;
const [label, suffix = ''] = firstLine.split(' — ', 2); }
return {
label, function toolStatus(line: ConsoleLine): string {
suffix, return line.toolStatus ?? line.toolCall?.state ?? '';
rest: rest.join('\n')
};
} }
function shouldRenderMarkdown(line: ConsoleLine): boolean { function shouldRenderMarkdown(line: ConsoleLine): boolean {
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system'; return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
} }
function bodyTextAfterToolSummary(line: ConsoleLine): string { function toolBodyText(line: ConsoleLine): string {
return toolSummary(line).rest; return detailOpen ? (line.expandedBody ?? line.body) : line.body;
} }
</script> </script>
@@ -107,20 +106,27 @@
</div> </div>
{:else if item.kind === 'tool'} {:else if item.kind === 'tool'}
<div class="tool-summary"> <div class="tool-summary">
<span class="tool-label">{toolSummary(item).label}</span> <span class="tool-label">{toolLabel(item)}</span>
<span class="tool-separator"></span> <span class={`tool-status ${item.toolCall?.state ?? ''}`}>{toolStatus(item)}</span>
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span> {#if item.detail}
<button
type="button"
class="tool-detail-button"
aria-expanded={detailOpen}
onclick={() => (detailOpen = !detailOpen)}
>detail</button>
{/if}
</div> </div>
{/if} {/if}
{#if item.compaction} {#if item.compaction}
<!-- rendered as one lifecycle item above --> <!-- rendered as one lifecycle item above -->
{:else if item.kind === 'tool'} {:else if item.kind === 'tool'}
{#if bodyTextAfterToolSummary(item)} {#if toolBodyText(item)}
<p class="console-plain-text"> <p class="console-plain-text">
{#if isBashTool(item)} {#if isBashTool(item)}
<AnsiText text={bodyTextAfterToolSummary(item)} /> <AnsiText text={toolBodyText(item)} />
{:else} {:else}
{bodyTextAfterToolSummary(item)} {toolBodyText(item)}
{/if} {/if}
</p> </p>
{/if} {/if}
@@ -147,11 +153,10 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if item.detail} {#if item.detail && detailOpen}
<details class="message-detail"> <div class="message-detail" role="region" aria-label={`${toolLabel(item)} detail`}>
<summary>detail</summary>
<p>{item.detail}</p> <p>{item.detail}</p>
</details> </div>
{/if} {/if}
</li> </li>
@@ -306,48 +311,46 @@
.tool-summary { .tool-summary {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 0; gap: 0.5rem;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.88rem; font-size: 0.88rem;
font-weight: 750; font-weight: 750;
} }
.tool-label { .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; flex: 1 1 auto;
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow: hidden;
color: var(--tui-cyan);
text-overflow: ellipsis;
white-space: nowrap;
} }
.tool-separator, .tool-status {
.tool-suffix { flex: 0 0 auto;
color: var(--tui-dark-gray); 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); color: var(--tui-red);
} }
.tool-state-running .tool-suffix, .tool-state-running .tool-status,
.tool-state-streaming_args .tool-suffix, .tool-state-streaming_args .tool-status,
.tool-state-pending .tool-suffix { .tool-state-pending .tool-status {
color: var(--tui-yellow); color: var(--tui-yellow);
} }
.tool-state-done .tool-suffix { .tool-state-done .tool-status {
color: var(--tui-dark-gray); color: var(--tui-dark-gray);
} }
.console-line.error-line .tool-status {
color: var(--tui-red);
}
.message-heading { .message-heading {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -412,13 +415,47 @@
color: var(--code); 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 { .message-detail {
margin-top: 0.35rem;
border-left: 2px solid var(--line);
padding-left: 0.6rem;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.84rem; font-size: 0.84rem;
} }
.message-detail summary { .message-detail p {
cursor: pointer; margin: 0;
font-weight: 800; overflow-wrap: anywhere;
white-space: pre-wrap;
}
@media (hover: none) {
.tool-detail-button {
opacity: 1;
pointer-events: auto;
}
} }
</style> </style>
@@ -313,26 +313,25 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
!toolLines[0].streaming, !toolLines[0].streaming,
"completed tool call should not remain streaming", "completed tool call should not remain streaming",
); );
assert( assertEquals(toolLines[0].toolCallLabel, "Bash($ pwd)");
toolLines[0].body.includes("$ pwd"), assertEquals(toolLines[0].toolStatus, "done");
"Bash command should be summarized",
);
assert( assert(
toolLines[0].body.includes("/repo"), toolLines[0].body.includes("/repo"),
"tool result should be folded into the Call block", "tool result should be folded into the Call block",
); );
assert( assert(
toolLines[0].body.includes("line9"), toolLines[0].body.includes("line9"),
"Bash result preview should include the ninth output line", "Bash preview should include the ninth output line",
); );
assert( assert(
!toolLines[0].body.includes("line10") && !toolLines[0].body.includes("line10") &&
!toolLines[0].body.includes("line12"), toolLines[0].body.includes("… +3 more lines"),
"Bash result preview should be capped at ten display lines", "Bash preview should retain its line cap",
); );
assert( assert(
toolLines[0].body.includes("… +3 more lines"), toolLines[0].expandedBody?.includes("line12") === true &&
"Bash result preview should show omitted output count", !toolLines[0].expandedBody?.includes("more lines"),
"Bash detail should show every returned output line",
); );
assert( assert(
toolLines[0].detail?.includes("id: call-1"), 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"); 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("elapsed"), line.body);
assert(!line.body.includes("stdout:"), line.body); assert(!line.body.includes("stdout:"), line.body);
assert(line.body.includes("ready\n"), 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 projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
const [line] = projection.lines.filter((line) => line.kind === "tool"); 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("elapsed"), line.body);
assert(!line.body.includes("stdout:"), line.body); assert(!line.body.includes("stdout:"), line.body);
assert(line.body.includes("[… earlier stdout omitted]\ntail\n"), 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); 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([ const projection = projectConsole([
{ {
eventId: "70", 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"); const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · CustomTool"); assertEquals(line.title, "Call · CustomTool");
assertEquals(line.body.split("\n").length, 7); assertEquals(line.toolCallLabel, 'CustomTool("first":"one","second":"two","third":"three","fourth":"four")');
assert(line.body.includes("CustomTool — done"), "tool state should be shown"); assertEquals(line.toolStatus, "done");
assertEquals(line.body.split("\n").length, 3);
assert( assert(
line.body.includes('"first": "one"'), line.body.includes("out1") && line.body.includes("… +3 more lines"),
"request preview should be shown", "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([ const projection = projectConsole([
{ {
eventId: "72", 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"); const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Grep"); assertEquals(line.title, "Call · Grep");
assert( assertEquals(line.toolCallLabel, "Grep(needle)");
line.body.includes("Grep — 6 matches"), assertEquals(line.toolStatus, "done");
"Grep summary should be shown",
);
assert(line.body.includes("query: needle"), "Grep query should be shown");
assert(line.body.includes("hit1"), "first result should be shown"); assert(line.body.includes("hit1"), "first result should be shown");
assert(line.body.includes("hit5"), "fifth 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( assert(
line.body.includes("… +1 more results"), 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"); const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Grep"); assertEquals(line.title, "Call · Grep");
assert( assertEquals(line.toolCallLabel, "Grep(needle)");
line.body.includes("Grep — Failed"), assertEquals(line.toolStatus, "error");
"error suffix should stay short",
);
assert( assert(
line.body.includes(message), line.body.includes(message),
"error detail should remain visible in the body", "error detail should remain visible in the body",
); );
assert( assert(
!line.body.includes(`Grep — ${message}`), !line.toolCallLabel?.includes(message),
"error detail should not be repeated in the suffix", "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.length, 1);
assertEquals(toolLines[0].title, "Call · Read"); assertEquals(toolLines[0].title, "Call · Read");
assert(toolLines[0].streaming, "streaming tool call should remain streaming"); assert(toolLines[0].streaming, "streaming tool call should remain streaming");
assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
assertEquals(toolLines[0].toolStatus, "reading…");
assert( assert(
toolLines[0].body.includes("/tmp/a.md") && toolLines[0].body.includes("/tmp/a.md"),
toolLines[0].body.includes("Read — reading"),
"Read call should render aggregate progress and path without content", "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"); const toolLines = projection.lines.filter((line) => line.kind === "tool");
assertEquals(toolLines.length, 1); assertEquals(toolLines.length, 1);
assertEquals(toolLines[0].title, "Call · Read"); assertEquals(toolLines[0].title, "Call · Read");
assert( assertEquals(toolLines[0].toolCallLabel, "Read(2 files)");
toolLines[0].body.includes("Read — 2 files read"), assertEquals(toolLines[0].toolStatus, "done");
"aggregate count should be shown",
);
assert( assert(
toolLines[0].body.includes("/tmp/a.md"), toolLines[0].body.includes("/tmp/a.md"),
"first path should be listed", "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"); const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Edit"); 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), [ assertEquals(line.diff?.map((row) => row.kind), [
"context", "context",
"remove", "remove",
@@ -1242,13 +1325,13 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
assertEquals(projection.status, "running"); assertEquals(projection.status, "running");
assertEquals( assertEquals(
projection.lines.map((line) => 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:seed user:false",
"user:new user:false", "user:new user:false",
"assistant:assistant reply:false", "assistant:assistant reply:false",
"tool:Read1 file read\n /tmp/a.md:false", "tool:Read(1 file)\n /tmp/a.md:false",
"status:Compacting…:true", "status:Compacting…:true",
"in_flight:partial: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(( const toolLines = projection.lines.filter((line) => line.kind === "tool");
line, const bodies = toolLines.map((line) => line.body);
) => line.body); assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
assertEquals(bodies[0], "Read — 1 file read\n src/main.rs"); assertEquals(bodies[0], " src/main.rs");
assert( assert(
projection.lines[0].detail?.includes("from src/main.rs"), projection.lines[0].detail?.includes("from src/main.rs"),
"Read summary detail path should be relative", "Read summary detail path should be relative",
); );
assert( assert(
bodies.some((body) => toolLines.some((line) =>
body.includes("Writeout.txt") && body.includes("Wrote out.txt") 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( assert(
bodies.some((body) => toolLines.some((line) =>
body.includes("Editsrc/main.rs") && body.includes("Edited src/main.rs") 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( assert(
bodies.some((body) => bodies.some((body) =>
@@ -11,6 +11,7 @@ import type {
InternalWorkerSnapshot, InternalWorkerSnapshot,
Segment, Segment,
} from "$lib/generated/protocol"; } from "$lib/generated/protocol";
import { stringify as stringifyYaml } from "yaml";
import { workspaceRoute } from "$lib/workspace/api/http"; import { workspaceRoute } from "$lib/workspace/api/http";
import { import {
applyRunActivityEvent, applyRunActivityEvent,
@@ -86,6 +87,9 @@ export type ConsoleLine = {
kind: ConsoleLineKind; kind: ConsoleLineKind;
title: string; title: string;
body: string; body: string;
expandedBody?: string;
toolCallLabel?: string;
toolStatus?: string;
detail?: string; detail?: string;
compaction?: ConsoleCompaction; compaction?: ConsoleCompaction;
diff?: ConsoleDiffLine[]; diff?: ConsoleDiffLine[];
@@ -1376,7 +1380,10 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
title: item.title.startsWith("Call · Tool result") title: item.title.startsWith("Call · Tool result")
? item.title ? item.title
: `Call · ${toolCall.name}`, : `Call · ${toolCall.name}`,
body: renderToolCall(toolCall), body: renderToolResponse(toolCall),
expandedBody: renderToolResponse(toolCall, true),
toolCallLabel: toolCallSignature(toolCall),
toolStatus: toolCallStatus(toolCall),
detail: toolCallDetail(toolCall), detail: toolCallDetail(toolCall),
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined, diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal, 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) { switch (toolCall.name) {
case "Read": case "Read":
return renderReadTool(toolCall); return renderReadTool(toolCall);
@@ -1395,14 +1402,54 @@ function renderToolCall(toolCall: ToolCallView): string {
case "Glob": case "Glob":
return renderSearchTool(toolCall); return renderSearchTool(toolCall);
case "Grep": case "Grep":
return renderGrepTool(toolCall); return renderGrepTool(toolCall, expanded);
case "Bash": case "Bash":
return renderBashTool(toolCall); return renderBashTool(toolCall, expanded);
default: 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[] { function aggregateReadToolLines(lines: ConsoleLine[]): ConsoleLine[] {
const result: ConsoleLine[] = []; const result: ConsoleLine[] = [];
let index = 0; let index = 0;
@@ -1434,9 +1481,6 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
const paths = calls.map(readPath); const paths = calls.map(readPath);
const visiblePaths = inProgress ? paths.slice(-3) : paths; const visiblePaths = inProgress ? paths.slice(-3) : paths;
const body = compactLines([ const body = compactLines([
inProgress
? `Read — reading (${count} file${plural(count)}…)`
: `Read — ${count} file${plural(count)} read`,
visiblePaths.map((path) => ` ${path}`).join("\n"), visiblePaths.map((path) => ` ${path}`).join("\n"),
inProgress && paths.length > visiblePaths.length inProgress && paths.length > visiblePaths.length
? ` … (${paths.length - visiblePaths.length} earlier)` ? ` … (${paths.length - visiblePaths.length} earlier)`
@@ -1447,6 +1491,8 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
kind: "tool", kind: "tool",
title: "Call · Read", title: "Call · Read",
body, body,
toolCallLabel: `Read(${count} file${plural(count)})`,
toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done",
detail: calls.map(readDetail).join("\n\n"), detail: calls.map(readDetail).join("\n\n"),
eventId: group.at(-1)?.eventId, eventId: group.at(-1)?.eventId,
source: "event", source: "event",
@@ -1483,32 +1529,16 @@ function readDetail(toolCall: ToolCallView): string {
]); ]);
} }
function renderReadTool(toolCall: ToolCallView): string { function renderReadTool(_toolCall: ToolCallView): string {
return `Read — ${readPath(toolCall)} (${stateSuffix(toolCall.state)})`; return "";
} }
function renderWriteTool(toolCall: ToolCallView): string { function renderWriteTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall); return knownToolResultText(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),
]);
} }
function renderEditTool(toolCall: ToolCallView): string { function renderEditTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall); return knownToolResultText(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),
]);
} }
function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined { function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined {
@@ -1591,52 +1621,20 @@ function lcsTable(oldLines: string[], newLines: string[]): number[][] {
} }
function renderSearchTool(toolCall: ToolCallView): string { function renderSearchTool(toolCall: ToolCallView): string {
const summary = toolCall.summary?.trim(); return knownToolResultText(toolCall) ?? "";
return compactLines([
`${toolCall.name}${toolHeaderSuffix(toolCall, summary)}`,
knownToolResultText(toolCall),
]);
} }
function renderGrepTool(toolCall: ToolCallView): string { function renderGrepTool(toolCall: ToolCallView, expanded: boolean): string {
const summary = toolCall.summary?.trim(); const result = knownToolResultText(toolCall);
return compactLines([ return expanded ? result ?? "" : cappedResultSection(result, 5) ?? "";
`Grep — ${toolHeaderSuffix(toolCall, summary)}`,
grepQueryText(toolCall),
cappedResultSection(knownToolResultText(toolCall), 5),
]);
} }
function toolHeaderSuffix( function renderBashTool(toolCall: ToolCallView, expanded: boolean): string {
toolCall: ToolCallView, if (["done", "error"].includes(toolCall.state)) {
summary?: string, const result = resultText(toolCall);
): string { return expanded ? result ?? "" : cappedDisplaySection(result, 10) ?? "";
if (toolCall.state === "error") {
return "Failed";
} }
return summary ? firstLine(summary) : stateSuffix(toolCall.state); return renderLiveCommandOutput(toolCall.command) ?? "";
}
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),
]);
} }
function commandStateSuffix(toolCall: ToolCallView): string { function commandStateSuffix(toolCall: ToolCallView): string {
@@ -1690,12 +1688,9 @@ function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined
]); ]);
} }
function renderDefaultTool(toolCall: ToolCallView): string { function renderDefaultTool(toolCall: ToolCallView, expanded: boolean): string {
return compactLines([ const result = resultText(toolCall);
`${toolCall.name}${stateSuffix(toolCall.state)}`, return expanded ? result ?? "" : cappedDisplaySection(result, 3) ?? "";
cappedDisplaySection(argsText(toolCall), 3),
cappedDisplaySection(resultText(toolCall), 3),
]);
} }
function toolCallDetail(toolCall: ToolCallView): string { function toolCallDetail(toolCall: ToolCallView): string {
@@ -1713,10 +1708,30 @@ function toolCallDetail(toolCall: ToolCallView): string {
} }
function resultText(toolCall: ToolCallView): string | undefined { function resultText(toolCall: ToolCallView): string | undefined {
if (toolCall.output) { const text = toolCall.output || toolCall.summary;
return toolCall.output; 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 { function knownToolResultText(toolCall: ToolCallView): string | undefined {
@@ -1803,7 +1818,7 @@ function argsText(toolCall: ToolCallView): string {
return ""; return "";
} }
const parsed = parseJson(raw); const parsed = parseJson(raw);
return parsed === undefined ? raw : jsonPreview(parsed); return parsed === undefined ? raw : stringifyYaml(parsed).trimEnd();
} }
function parsedArgs( function parsedArgs(
@@ -1830,21 +1845,6 @@ function compactLines(lines: Array<string | undefined | null | false>): string {
return lines.filter((line): line is string => Boolean(line)).join("\n"); 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( function cappedDisplaySection(
value: string | undefined, value: string | undefined,
maxLines: number, maxLines: number,
@@ -402,7 +402,7 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
consoleLine.includes("item.kind === 'tool'") && consoleLine.includes("item.kind === 'tool'") &&
consoleLine.includes("{#if isBashTool(item)}") && consoleLine.includes("{#if isBashTool(item)}") &&
consoleLine.includes( consoleLine.includes(
"<AnsiText text={bodyTextAfterToolSummary(item)} />", "<AnsiText text={toolBodyText(item)} />",
) && ) &&
consoleLine.includes( consoleLine.includes(
".console-line.tool-bash .console-plain-text", ".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('<details class="message-detail">'),
"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 () => { Deno.test("Worker Console renders Edit diffs without preformatted template gaps", async () => {
const consoleLine = await Deno.readTextFile( const consoleLine = await Deno.readTextFile(
new URL("./ConsoleLineItem.svelte", import.meta.url), new URL("./ConsoleLineItem.svelte", import.meta.url),