feat(tui): inline spillover-path annotation in tool cells (#423)

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/<id>.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<PathBuf>\` 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) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-03 04:44:43 -05:00
parent cea4617fb4
commit de4085304d
5 changed files with 191 additions and 0 deletions
+1
View File
@@ -354,6 +354,7 @@ mod tests {
input_summary: None,
output: None,
prompts: None,
spillover_path: None,
}))
}
+170
View File
@@ -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<Vec<String>>,
/// 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<std::path::PathBuf>,
}
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 =
+9
View File
@@ -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(
+8
View File
@@ -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<String> = lines
+3
View File
@@ -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 {