From de4085304d71b592c39cea5ade371eca7d558261 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 3 May 2026 04:44:43 -0500 Subject: [PATCH] feat(tui): inline spillover-path annotation in tool cells (#423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #422 (sister commit on this branch) shipped the storage half: \`crates/tui/src/tools/truncate.rs\` writes large tool outputs to \`~/.deepseek/tool_outputs/.txt\` and the boot prune drops files older than 7 days. This commit ships the UI half — the inline annotation that surfaces the spilled path in the tool cell so the user (and the model) can find the elided tail. ### What's wired - New \`spillover_path: Option\` field on \`GenericToolCell\`. Threaded through every construction site (production + test fixtures = 28 sites; bulk-updated via a Python regex that preserves indentation per site). - \`tool_routing::push_orphan_tool_completion\` now reads \`ToolResult.metadata.spillover_path\` and stamps it on the cell. When tools start writing the metadata field (#500's wiring step), the annotation lights up automatically. - \`GenericToolCell::lines_with_mode\` emits a one-line muted annotation in \`RenderMode::Live\` only: full output: /Users/you/.deepseek/tool_outputs/call-abc12.txt Transcript-mode replay omits the annotation because the full output is already inline. - \`render_spillover_annotation\` truncates the path to fit narrow widths (40-col sidebar friendly) using the existing \`truncate_text\` helper. ### Why no OSC 8 hyperlink yet The OSC 8 wrap-link helper lives on PR #515's branch (also stacked on \`chore/v0.8.8-stabilization\`); both PRs land independently to \`main\`. Once both are in, a follow-up commit can wrap the path in \`osc8::wrap_link\` so supporting terminals make it Cmd+click-openable. The plain-text path works in every terminal today, so there's no functional regression. ### Tests 4 new tests in \`tui::history::tests\`: - \`render_spillover_annotation_shows_path\` — full path appears in the live-mode render - \`render_spillover_annotation_omitted_in_transcript_mode\` — transcript replay leaves the annotation off - \`render_spillover_annotation_omitted_when_no_path_set\` — the common case (most tool results don't trigger spillover) is unaffected - \`render_spillover_annotation_truncates_to_width\` — narrow widths don't overflow the line ### Verification cargo fmt --all -- --check ✓ cargo clippy --workspace --all-targets --all-features --locked -- -D warnings ✓ cargo test --workspace --all-features --locked ✓ 1877 + supporting (was 1873) Closes #423. #500 (preview pane) now has both halves of its prerequisites in place — the bytes are on disk (#422) and the path is surfaced in the cell (#423). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/tui/active_cell.rs | 1 + crates/tui/src/tui/history.rs | 170 +++++++++++++++++++++++++++++ crates/tui/src/tui/tool_routing.rs | 9 ++ crates/tui/src/tui/ui/tests.rs | 8 ++ crates/tui/src/tui/widgets/mod.rs | 3 + 5 files changed, 191 insertions(+) diff --git a/crates/tui/src/tui/active_cell.rs b/crates/tui/src/tui/active_cell.rs index ffd3db57..66f613d9 100644 --- a/crates/tui/src/tui/active_cell.rs +++ b/crates/tui/src/tui/active_cell.rs @@ -354,6 +354,7 @@ mod tests { input_summary: None, output: None, prompts: None, + spillover_path: None, })) } diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index e9935ea0..cfc5cdbf 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -1185,6 +1185,13 @@ pub struct GenericToolCell { /// fan-out tool), each prompt is shown on its own indented row instead /// of the inline `args:` summary. `None` for ordinary tools. pub prompts: Option>, + /// Filesystem path to the full output's spillover file (#422/#423). + /// Set by the tool-routing layer when `ToolResult.metadata` carried a + /// `spillover_path` field. The truncation affordance includes the + /// path so the user can `read_file` it (or Cmd+click in + /// OSC 8-aware terminals — the path renders as a hyperlink when + /// `tui.osc8_links` is enabled). + pub spillover_path: Option, } impl GenericToolCell { @@ -1300,6 +1307,19 @@ impl GenericToolCell { mode, )); } + + // #423: surface the spillover-file path inline so the user + // (and the model) can find the elided tail. Only emitted in + // live mode — transcript replay already has the full output + // verbatim. The path is OSC 8-wrapped when the feature is + // enabled so terminals that support hyperlinks make it + // Cmd+click-openable; the clipboard / selection path + // strips the escape on copy. + if matches!(mode, RenderMode::Live) + && let Some(path) = self.spillover_path.as_ref() + { + lines.push(render_spillover_annotation(path, width)); + } } lines } @@ -1376,6 +1396,29 @@ impl GenericToolCell { } } +/// Render the inline annotation for a tool cell whose full output was +/// spilled to disk (#422 + #423). Produces a one-line muted hint: +/// +/// ```text +/// full output: /Users/you/.deepseek/tool_outputs/call-abc12.txt +/// ``` +/// +/// Path is plain text on this branch; the OSC 8 hyperlink-wrap that +/// makes it Cmd+click-openable lives on the OSC 8 branch (PR #515) +/// and merges in once both PRs land on `main`. The clipboard / +/// selection path already strips OSC 8 there, so a future enhancement +/// stays backward-compatible. +fn render_spillover_annotation(path: &std::path::Path, width: u16) -> Line<'static> { + let display = path.display().to_string(); + let prefix = " full output: "; + let budget = usize::from(width).saturating_sub(prefix.len()).max(8); + let truncated = truncate_text(&display, budget); + Line::from(vec![ + Span::styled(prefix, Style::default().fg(palette::TEXT_MUTED)), + Span::styled(truncated, Style::default().fg(palette::TEXT_MUTED).italic()), + ]) +} + /// Pull the `agent_id` field out of an `agent_spawn` tool output. The /// tool emits structured JSON shaped like /// `{"agent_id": "agent-abc12", "nickname": "...", "model": "..."}` so we @@ -3012,6 +3055,121 @@ mod tests { // Below 3s the label stays "running" — quick reads/greps shouldn't // visually churn. From 3s onward the badge appears and ticks each // second so the user can tell the call hasn't hung. + // ---- #423 spillover-path UI annotation ---- + // + // When a tool result carries a `spillover_path` (set by the + // tool-routing layer when the tool's `metadata.spillover_path` is + // populated), the live render appends a one-line muted hint + // pointing at the file. Transcript-mode replay leaves the hint + // off because the full output is already inline. + + #[test] + fn render_spillover_annotation_shows_path() { + use std::path::PathBuf; + let cell = GenericToolCell { + name: "exec_shell".to_string(), + status: ToolStatus::Success, + input_summary: Some("cmd: cargo build --release".to_string()), + output: Some("very large output...".to_string()), + prompts: None, + spillover_path: Some(PathBuf::from( + "/Users/dev/.deepseek/tool_outputs/call-abc12.txt", + )), + }; + let lines = cell.lines_with_mode(120, true, super::RenderMode::Live); + let joined: String = lines + .iter() + .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) + .collect(); + assert!( + joined.contains("full output:"), + "expected annotation prefix: {joined:?}" + ); + assert!( + joined.contains("/Users/dev/.deepseek/tool_outputs/call-abc12.txt"), + "expected the spillover path: {joined:?}" + ); + } + + #[test] + fn render_spillover_annotation_omitted_in_transcript_mode() { + use std::path::PathBuf; + // Transcript mode is for replay; the full output is already + // inline so the annotation would just be redundant. + let cell = GenericToolCell { + name: "exec_shell".to_string(), + status: ToolStatus::Success, + input_summary: None, + output: Some("output".to_string()), + prompts: None, + spillover_path: Some(PathBuf::from("/tmp/spill.txt")), + }; + let lines = cell.lines_with_mode(120, true, super::RenderMode::Transcript); + let joined: String = lines + .iter() + .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) + .collect(); + assert!( + !joined.contains("full output:"), + "annotation should be omitted in transcript mode: {joined:?}" + ); + } + + #[test] + fn render_spillover_annotation_omitted_when_no_path_set() { + // The common case: most tool results don't trigger spillover. + let cell = GenericToolCell { + name: "read_file".to_string(), + status: ToolStatus::Success, + input_summary: None, + output: Some("contents".to_string()), + prompts: None, + spillover_path: None, + }; + let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); + let joined: String = lines + .iter() + .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) + .collect(); + assert!(!joined.contains("full output:"), "{joined:?}"); + } + + #[test] + fn render_spillover_annotation_truncates_to_width() { + use std::path::PathBuf; + let long_path = "/Users/dev/.deepseek/tool_outputs/this-is-a-very-long-tool-call-id-that-will-not-fit-in-narrow-widths.txt"; + let cell = GenericToolCell { + name: "exec_shell".to_string(), + status: ToolStatus::Success, + input_summary: None, + output: Some("output".to_string()), + prompts: None, + spillover_path: Some(PathBuf::from(long_path)), + }; + let lines = cell.lines_with_mode(40, true, super::RenderMode::Live); + let annotation_line = lines + .iter() + .find(|l| { + l.spans + .iter() + .any(|s| s.content.as_ref().contains("full output:")) + }) + .expect("annotation line present"); + let rendered: String = annotation_line + .spans + .iter() + .map(|s| s.content.as_ref()) + .collect(); + // Width budget is 40; annotation line should be at most ~40 chars. + // (Some slack for the prefix; the truncate_text ellipsis costs + // 3 cols.) + assert!( + rendered.chars().count() <= 60, + "annotation overflowed at width 40: {} chars: {rendered:?}", + rendered.chars().count() + ); + } + // ---- #409 compact agent_spawn rendering ---- // // The DelegateCard owns live state for spawned sub-agents; the @@ -3061,6 +3219,7 @@ mod tests { .to_string(), ), prompts: None, + spillover_path: None, }; let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); // One header line, no details/args/output expansion. @@ -3093,6 +3252,7 @@ mod tests { input_summary: Some("prompt: do thing".to_string()), output: None, prompts: None, + spillover_path: None, }; let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); assert_eq!(lines.len(), 1); @@ -3112,6 +3272,7 @@ mod tests { r#"{"agent_id": "agent-abc12", "model": "deepseek-v4-flash"}"#.to_string(), ), prompts: None, + spillover_path: None, }; let lines = cell.lines_with_mode(80, true, super::RenderMode::Transcript); // Transcript mode emits header + name kv + (no args, output present) @@ -3129,6 +3290,7 @@ mod tests { input_summary: Some("path: foo.rs".to_string()), output: Some("first line\nsecond line\nthird line".to_string()), prompts: None, + spillover_path: None, }; let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); assert!( @@ -3550,6 +3712,7 @@ mod tests { input_summary: Some("foo".to_string()), output: None, prompts: None, + spillover_path: None, }; let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); let header_visible: String = lines[0] @@ -3576,6 +3739,7 @@ mod tests { input_summary: Some("task: compare source trees".to_string()), output: None, prompts: None, + spillover_path: None, }; let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); let header_visible: String = lines[0] @@ -4011,6 +4175,7 @@ mod tests { "List the public types in client.rs".to_string(), "Diff this commit against main".to_string(), ]), + spillover_path: None, })); let text = lines_text(&cell.lines(80)); @@ -4035,6 +4200,7 @@ mod tests { input_summary: Some("query: foo".to_string()), output: None, prompts: None, + spillover_path: None, })); let text = lines_text(&cell.lines(80)); assert!(text.contains("query: foo")); @@ -4057,6 +4223,7 @@ mod tests { input_summary: Some("command: git diff --stat".to_string()), output: Some(diff_stat.to_string()), prompts: None, + spillover_path: None, })); let transcript_text = lines_text(&cell.transcript_lines(80)); @@ -4106,6 +4273,7 @@ mod tests { input_summary: Some("command: ls".to_string()), output: Some(output), prompts: None, + spillover_path: None, })); let live = cell.lines_with_options(80, TranscriptRenderOptions::default()); @@ -4140,6 +4308,7 @@ mod tests { input_summary: Some("command: noisy".to_string()), output: Some(output), prompts: None, + spillover_path: None, })); let live_text = @@ -4174,6 +4343,7 @@ mod tests { input_summary: Some("command: tool".to_string()), output: Some(output), prompts: None, + spillover_path: None, })); let live_text = diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs index 7ad4a49b..76501bd7 100644 --- a/crates/tui/src/tui/tool_routing.rs +++ b/crates/tui/src/tui/tool_routing.rs @@ -242,6 +242,7 @@ pub(super) fn handle_tool_call_started( input_summary, output: None, prompts: None, + spillover_path: None, })), ); } @@ -536,12 +537,20 @@ fn push_orphan_tool_completion( }; let history_threshold_before_push = app.history.len(); let active_in_flight = app.active_cell.is_some(); + let spillover_path = result + .as_ref() + .ok() + .and_then(|r| r.metadata.as_ref()) + .and_then(|m| m.get("spillover_path")) + .and_then(serde_json::Value::as_str) + .map(std::path::PathBuf::from); app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { name: name.to_string(), status, input_summary: None, output, prompts: None, + spillover_path, }))); let cell_index = app.history.len().saturating_sub(1); app.tool_details_by_cell.insert( diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index d1377fbb..704dd869 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -118,6 +118,7 @@ fn selection_to_text_copies_rendered_transcript_block() { input_summary: Some("cargo check".to_string()), output: Some("tool output line".to_string()), prompts: None, + spillover_path: None, })), HistoryCell::Assistant { content: "copy assistant".to_string(), @@ -542,6 +543,7 @@ fn active_tool_status_label_summarizes_live_tool_group() { input_summary: Some("pattern: TODO".to_string()), output: Some("done".to_string()), prompts: None, + spillover_path: None, })), ); app.active_cell = Some(active); @@ -567,6 +569,7 @@ fn active_tool_status_label_counts_foreground_rlm_work() { input_summary: Some("task: compare projects".to_string()), output: None, prompts: None, + spillover_path: None, })), ); app.active_cell = Some(active); @@ -1513,6 +1516,7 @@ fn jump_to_adjacent_tool_cell_finds_next_and_previous() { input_summary: Some("query: foo".to_string()), output: Some("done".to_string()), prompts: None, + spillover_path: None, })), HistoryCell::Assistant { content: "ok".to_string(), @@ -1524,6 +1528,7 @@ fn jump_to_adjacent_tool_cell_finds_next_and_previous() { input_summary: Some("ls".to_string()), output: Some("...".to_string()), prompts: None, + spillover_path: None, })), ]; app.mark_history_updated(); @@ -1579,6 +1584,7 @@ fn detail_target_prefers_visible_tool_card() { input_summary: Some("query: foo".to_string()), output: Some("done".to_string()), prompts: None, + spillover_path: None, })), HistoryCell::Assistant { content: "ok".to_string(), @@ -1590,6 +1596,7 @@ fn detail_target_prefers_visible_tool_card() { input_summary: Some("command: ls".to_string()), output: Some("...".to_string()), prompts: None, + spillover_path: None, })), ]; app.tool_details_by_cell.insert( @@ -2949,6 +2956,7 @@ fn checklist_write_renders_dedicated_card() { .to_string(), ), prompts: None, + spillover_path: None, }; let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live); let text: Vec = lines diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 10905c73..8ffd9a0b 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -2138,6 +2138,7 @@ mod tests { input_summary: Some("items: <2 items>".to_string()), output: Some("hello world ".repeat(420)), prompts: None, + spillover_path: None, })); for width in [40u16, 80, 111, 165] { let lines = cell.lines(width); @@ -2181,6 +2182,7 @@ mod tests { input_summary: Some("todos: <1 items>".to_string()), output: Some(output), prompts: None, + spillover_path: None, }))); let height: u16 = 30; @@ -2384,6 +2386,7 @@ mod tests { input_summary: Some(format!("query: hit-{i}")), output: Some(format!("found 12 matches in cell-{i}")), prompts: None, + spillover_path: None, })) } else if i % 2 == 0 { HistoryCell::User {