feat(tui): compact agent_spawn rendering — single line, DelegateCard owns the rest (#409)

The transcript previously rendered each \`agent_spawn\` call as a 3-4 line
generic tool block (header + name kv + args summary + output JSON) AND
its companion \`DelegateCard\` (header + live action lines + summary).
Four parallel spawns produced ≥16 lines of nearly-identical scaffolding
before the model said anything useful.

In live mode \`agent_spawn\` now renders as a single header line —
\`◐ delegate · agent-abc12  [running]\` — with the agent id pulled from
the tool's JSON output. The DelegateCard remains the source of truth
for live action progress and the final summary; the generic block is no
longer fighting it for attention.

Transcript-mode replay (used by \`/pager\`, session export, and the
detail pager opened with Alt+V) keeps the full multi-line block so
debug history is preserved.

### What's wired

- \`GenericToolCell::lines_with_mode\` early-returns
  \`render_agent_spawn_compact\` when \`name == "agent_spawn"\` and
  \`mode == RenderMode::Live\`.
- New \`render_agent_spawn_compact\` builds one header line with the
  family glyph (Delegate), the spawned agent id (or \`…\`
  placeholder while the spawn is in flight), and the tool status.
- New \`extract_agent_id(output)\` parser: deterministic, allocation-free
  string scan for \`"agent_id"\` → quoted value. Avoids dragging serde
  into a render hot path.

### Tests

- 4 \`extract_agent_id\` tests: typical JSON, extra whitespace, missing
  key (None), empty string id (None).
- 4 render tests: live one-liner contains agent id + status with no
  args/name kv leaking, pending render uses \`…\` placeholder,
  transcript mode keeps the full block, non-spawn tools (read_file)
  still render their normal multi-line block.

### Verification

cargo fmt --all -- --check                                          ✓
cargo clippy --workspace --all-targets --all-features --locked --   -D warnings   ✓
cargo test --workspace --all-features --locked                      ✓ 1842 + supporting

Closes #409

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-03 03:44:19 -05:00
parent c52f2c46f1
commit b54a708cf7
+185
View File
@@ -1205,6 +1205,18 @@ impl GenericToolCell {
if let Some(lines) = self.try_render_as_checklist(width, low_motion, mode) {
return lines;
}
// Issue #409: `agent_spawn` already gets a dedicated `DelegateCard`
// that owns the live action tree, status, and final summary. The
// generic tool block for the same call duplicates that signal at
// 3-4 lines per spawn — N parallel spawns multiply the noise. In
// live mode, render one compact summary line and let the
// DelegateCard be the source of truth. Transcript mode keeps the
// full block so session replay remains complete.
if matches!(mode, RenderMode::Live) && self.name == "agent_spawn" {
return self.render_agent_spawn_compact(low_motion);
}
let mut lines = Vec::new();
// Map the actual tool name (e.g. `agent_spawn`, `apply_patch`) to a
// family rather than the catch-all `"Tool"` title — this is what
@@ -1292,6 +1304,32 @@ impl GenericToolCell {
lines
}
/// Render `agent_spawn` as a single compact summary line for live
/// mode (#409). The companion `DelegateCard` already carries the
/// live action tree, status, and final summary; this line is just
/// the pointer that says "a spawn happened, here's the agent id".
///
/// Output shape (header):
/// `◐ delegate · agent_spawn agent-abc12 [running]`
/// Falls back to a placeholder when the spawn is still pending and
/// no agent id has been assigned yet.
fn render_agent_spawn_compact(&self, low_motion: bool) -> Vec<Line<'static>> {
let family = crate::tui::widgets::tool_card::ToolFamily::Delegate;
let agent_id = self
.output
.as_deref()
.and_then(extract_agent_id)
.unwrap_or("");
vec![render_tool_header_with_family_and_summary(
family,
Some(agent_id),
tool_status_label(self.status),
self.status,
None,
low_motion,
)]
}
/// If this cell is a checklist/todo write/add/update and the output is
/// parseable as a checklist snapshot, render a purpose-built checklist
/// card instead of the generic `name: ... { json }` block (issue #241).
@@ -1338,6 +1376,28 @@ impl GenericToolCell {
}
}
/// 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
/// look for the `agent_id` key and return its string value.
///
/// Returns `None` for outputs we can't parse as JSON or that lack the
/// expected key — the caller falls back to a placeholder so a still-pending
/// spawn renders cleanly.
fn extract_agent_id(output: &str) -> Option<&str> {
// Cheap, deterministic, no allocations: scan for the literal key.
// Avoids dragging serde_json into a render hot path on every frame.
let key = "\"agent_id\"";
let key_idx = output.find(key)?;
let rest = &output[key_idx + key.len()..];
let colon = rest.find(':')?;
let after_colon = rest[colon + 1..].trim_start();
let after_colon = after_colon.strip_prefix('"')?;
let end = after_colon.find('"')?;
let id = &after_colon[..end];
(!id.is_empty()).then_some(id)
}
fn is_checklist_tool_name(name: &str) -> bool {
matches!(
name,
@@ -2952,6 +3012,131 @@ 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.
// ---- #409 compact agent_spawn rendering ----
//
// The DelegateCard owns live state for spawned sub-agents; the
// generic tool block previously duplicated that signal at 3-4 lines
// per spawn. In live mode we now render a single compact line that
// points at the spawned agent id; transcript-mode replay keeps the
// full block so debug history is intact.
#[test]
fn extract_agent_id_pulls_id_from_json_output() {
let output =
r#"{"agent_id": "agent-abc12", "nickname": "Beluga", "model": "deepseek-v4-flash"}"#;
assert_eq!(super::extract_agent_id(output), Some("agent-abc12"));
}
#[test]
fn extract_agent_id_handles_extra_whitespace() {
let output = r#"{
"agent_id" : "agent-xyz",
"model": "x"
}"#;
assert_eq!(super::extract_agent_id(output), Some("agent-xyz"));
}
#[test]
fn extract_agent_id_returns_none_when_missing() {
let output = r#"{"nickname": "Orca", "model": "x"}"#;
assert!(super::extract_agent_id(output).is_none());
assert!(super::extract_agent_id("(not json)").is_none());
assert!(super::extract_agent_id("").is_none());
}
#[test]
fn extract_agent_id_returns_none_for_empty_id() {
let output = r#"{"agent_id": "", "model": "x"}"#;
assert!(super::extract_agent_id(output).is_none());
}
#[test]
fn agent_spawn_renders_single_compact_line_in_live_mode() {
let cell = GenericToolCell {
name: "agent_spawn".to_string(),
status: ToolStatus::Running,
input_summary: Some("prompt: do thing".to_string()),
output: Some(
r#"{"agent_id": "agent-abc12", "nickname": "Beluga", "model": "deepseek-v4-flash"}"#
.to_string(),
),
prompts: None,
};
let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
// One header line, no details/args/output expansion.
assert_eq!(lines.len(), 1, "expected exactly 1 line, got {:?}", lines);
let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
// Header carries the agent id and the running status.
assert!(
rendered.contains("agent-abc12"),
"expected agent id in header: {rendered:?}"
);
assert!(
rendered.contains("running"),
"expected status in header: {rendered:?}"
);
// No verbose `args:` / `name:` rows.
assert!(
!rendered.contains("args"),
"args should be hidden: {rendered:?}"
);
}
#[test]
fn agent_spawn_pending_render_uses_placeholder_id() {
// No output yet → use the … placeholder so the user still sees a
// header line during the brief gap between tool-call-started and
// the spawn returning the agent_id.
let cell = GenericToolCell {
name: "agent_spawn".to_string(),
status: ToolStatus::Running,
input_summary: Some("prompt: do thing".to_string()),
output: None,
prompts: None,
};
let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
assert_eq!(lines.len(), 1);
let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(rendered.contains('\u{2026}'), "{rendered:?}"); // …
}
#[test]
fn agent_spawn_transcript_mode_keeps_full_block() {
// Transcript mode is for replay/debug — preserve the full block
// so session export still carries the args/output verbatim.
let cell = GenericToolCell {
name: "agent_spawn".to_string(),
status: ToolStatus::Success,
input_summary: Some("prompt: do thing".to_string()),
output: Some(
r#"{"agent_id": "agent-abc12", "model": "deepseek-v4-flash"}"#.to_string(),
),
prompts: None,
};
let lines = cell.lines_with_mode(80, true, super::RenderMode::Transcript);
// Transcript mode emits header + name kv + (no args, output present)
// + output rows. At minimum more than the live one-liner.
assert!(lines.len() > 1, "expected verbose transcript render");
}
#[test]
fn other_tools_are_unaffected_by_agent_spawn_compact_path() {
// Only `agent_spawn` is collapsed — `read_file` and friends
// continue to render their normal multi-line block in live mode.
let cell = GenericToolCell {
name: "read_file".to_string(),
status: ToolStatus::Success,
input_summary: Some("path: foo.rs".to_string()),
output: Some("first line\nsecond line\nthird line".to_string()),
prompts: None,
};
let lines = cell.lines_with_mode(80, true, super::RenderMode::Live);
assert!(
lines.len() > 1,
"non-spawn tools should keep their full block"
);
}
// ---- #403 concise todo / checklist update rendering ----
//
// The tool emits an "Updated todo #N to STATUS" leading line plus a