@setoelkahfi / sigit / commits / 7692418

Collapse tool calls in the TUI to title lines with /tools

Every tool call used to land in the transcript as a plain system line, and argument previews stayed there forever, burying the conversation under command dumps. Each call is now a single collapsed dim line with a derived title: the command for run_command, the path for file tools, the pattern for search and glob, server and tool for MCP calls, all truncated to one line. /tools (on, off, or bare to flip) expands every entry to show the pretty-printed arguments and a capped tail of the tool's result, including the denial reason when policy skipped a call, then collapses them again. The permission approval prompts keep showing the full command: what you are approving must never be hidden. Display only. The model still receives full outputs, ACP and the session store are untouched, and the result preview rides a separate display update so nothing changes in the tool loop itself.

paydii committed Jul 5, 2026 at 09:44 UTC 769241839a8eb50a792e6bc713789acf9381014f
1 file changed +391 -9
src/chat.rs
+391 -9
@@ -62,6 +62,127 @@ pub(crate) fn parse_rich_text_segments(text: &str) -> Vec<(String, bool)> {
62 segments
63 }
64
65 +// ── Tool-call display helpers ─────────────────────────────────────────────────
66 +//
67 +// Pure helpers for the TUI's collapsible tool-call entries. Top-level (not in
68 +// `mod tui`) so they are testable on every target; only the Unix TUI consumes
69 +// them, hence the non-Unix dead-code allowance.
70 +
71 +/// Max characters for the summary half of a tool-call title line.
72 +#[cfg_attr(not(unix), allow(dead_code))]
73 +const TOOL_TITLE_SUMMARY_MAX: usize = 60;
74 +
75 +/// Max characters of tool output kept for the expanded-entry result preview.
76 +#[cfg_attr(not(unix), allow(dead_code))]
77 +const TOOL_RESULT_PREVIEW_MAX: usize = 2000;
78 +
79 +/// Truncate to `max_chars` characters, ending with an ellipsis when cut.
80 +#[cfg_attr(not(unix), allow(dead_code))]
81 +fn truncate_with_ellipsis(text: &str, max_chars: usize) -> String {
82 + if text.chars().count() <= max_chars {
83 + return text.to_string();
84 + }
85 + let mut out: String = text.chars().take(max_chars.saturating_sub(1)).collect();
86 + out.push('…');
87 + out
88 +}
89 +
90 +/// One-line title for a tool call, Claude Code style: the tool name plus the
91 +/// argument that best identifies the call (`run_command · cargo test`,
92 +/// `edit_file · src/main.rs`, `sigit · list_issues` for MCP tools). Falls back
93 +/// to the bare tool name when the arguments are malformed or carry nothing
94 +/// summarizable.
95 +#[cfg_attr(not(unix), allow(dead_code))]
96 +pub(crate) fn tool_title(name: &str, arguments_json: &str) -> String {
97 + let args: Option<serde_json::Value> = serde_json::from_str(arguments_json).ok();
98 + let str_arg =
99 + |key: &str| -> Option<String> { Some(args.as_ref()?.get(key)?.as_str()?.to_string()) };
100 +
101 + let (label, summary) = if let Some(rest) = name.strip_prefix("mcp__") {
102 + // mcp__<server>__<tool> → "<server> · <tool>"
103 + match rest.split_once("__") {
104 + Some((server, tool)) => (server.to_string(), Some(tool.to_string())),
105 + None => (rest.to_string(), None),
106 + }
107 + } else {
108 + let summary = match name {
109 + "run_command" => str_arg("command"),
110 + "read_file" | "create_file" | "edit_file" | "multi_edit" | "delete_file" => {
111 + str_arg("path")
112 + }
113 + "search_files" | "glob" => str_arg("pattern"),
114 + _ => None,
115 + };
116 + (name.to_string(), summary)
117 + };
118 +
119 + // A title is a single line: collapse any internal whitespace runs
120 + // (multi-line commands) before truncating.
121 + let summary = summary
122 + .map(|s| s.split_whitespace().collect::<Vec<_>>().join(" "))
123 + .filter(|s| !s.is_empty())
124 + .map(|s| truncate_with_ellipsis(&s, TOOL_TITLE_SUMMARY_MAX));
125 +
126 + match summary {
127 + Some(summary) => format!("{label} · {summary}"),
128 + None => label,
129 + }
130 +}
131 +
132 +/// Pretty-print a tool call's JSON arguments for the expanded view; malformed
133 +/// JSON is shown raw.
134 +#[cfg_attr(not(unix), allow(dead_code))]
135 +pub(crate) fn pretty_tool_arguments(arguments_json: &str) -> String {
136 + serde_json::from_str::<serde_json::Value>(arguments_json)
137 + .ok()
138 + .and_then(|value| serde_json::to_string_pretty(&value).ok())
139 + .unwrap_or_else(|| arguments_json.to_string())
140 +}
141 +
142 +/// The tail of a tool's output for display under an expanded tool entry,
143 +/// capped at [`TOOL_RESULT_PREVIEW_MAX`] chars with a truncation note. Display
144 +/// only — the model always receives the full output.
145 +#[cfg_attr(not(unix), allow(dead_code))]
146 +pub(crate) fn cap_output_preview(output: &str) -> String {
147 + let total = output.chars().count();
148 + if total <= TOOL_RESULT_PREVIEW_MAX {
149 + return output.to_string();
150 + }
151 + let tail: String = output
152 + .chars()
153 + .skip(total - TOOL_RESULT_PREVIEW_MAX)
154 + .collect();
155 + format!("… (truncated — showing the last {TOOL_RESULT_PREVIEW_MAX} of {total} chars)\n{tail}")
156 +}
157 +
158 +/// The display lines of one tool-call entry. Collapsed: a single `▸ 🔧 title`
159 +/// line. Expanded: `▾ 🔧 title`, the indented argument detail, and the result
160 +/// preview under a `result:` label when present.
161 +#[cfg_attr(not(unix), allow(dead_code))]
162 +pub(crate) fn tool_entry_lines(
163 + title: &str,
164 + detail: &str,
165 + result_preview: Option<&str>,
166 + expanded: bool,
167 +) -> Vec<String> {
168 + if !expanded {
169 + return vec![format!("▸ 🔧 {title}")];
170 + }
171 + let mut out = vec![format!("▾ 🔧 {title}")];
172 + if !detail.is_empty() {
173 + for line in detail.split('\n') {
174 + out.push(format!(" {line}"));
175 + }
176 + }
177 + if let Some(result) = result_preview {
178 + out.push(" result:".to_string());
179 + for line in result.split('\n') {
180 + out.push(format!(" {line}"));
181 + }
182 + }
183 + out
184 +}
185 +
186 // ── Unix-only TUI ─────────────────────────────────────────────────────────────
187 //
188 // macOS + Linux only. Windows uses ACP mode instead.
@@ -100,6 +221,8 @@ mod tui {
221 System,
222 /// rainbow-colored banner art
223 Banner,
224 + /// a collapsible tool-call entry (`text` holds the title line)
225 + Tool,
226 }
227
228 struct ChatMessage {
@@ -107,6 +230,11 @@ mod tui {
230 text: String,
231 /// Qwen 3 reasoning extracted from `<think>` tags, if any.
232 think_block: Option<String>,
233 + /// pretty-printed tool arguments (Role::Tool only)
234 + tool_detail: Option<String>,
235 + /// tail of the tool output, filled in when the call finishes
236 + /// (Role::Tool only)
237 + tool_result: Option<String>,
238 }
239
240 impl ChatMessage {
@@ -115,6 +243,8 @@ mod tui {
243 role: Role::User,
244 text: text.into(),
245 think_block: None,
246 + tool_detail: None,
247 + tool_result: None,
248 }
249 }
250
@@ -125,6 +255,8 @@ mod tui {
255 role: Role::Assistant,
256 text: visible,
257 think_block: if think.is_empty() { None } else { Some(think) },
258 + tool_detail: None,
259 + tool_result: None,
260 }
261 }
262
@@ -133,6 +265,8 @@ mod tui {
265 role: Role::System,
266 text: text.into(),
267 think_block: None,
268 + tool_detail: None,
269 + tool_result: None,
270 }
271 }
272
@@ -141,6 +275,18 @@ mod tui {
275 role: Role::Banner,
276 text: text.into(),
277 think_block: None,
278 + tool_detail: None,
279 + tool_result: None,
280 + }
281 + }
282 +
283 + fn tool_call(title: impl Into<String>, detail: impl Into<String>) -> Self {
284 + Self {
285 + role: Role::Tool,
286 + text: title.into(),
287 + think_block: None,
288 + tool_detail: Some(detail.into()),
289 + tool_result: None,
290 }
291 }
292 }
@@ -148,8 +294,17 @@ mod tui {
294 // ── Inference updates from background task ────────────────────────────────
295
296 enum InferenceUpdate {
151 - /// show tool name in chat while it runs
152 - ToolUse(String),
297 + /// a tool call started — show a collapsible entry in the transcript
298 + ToolUse {
299 + name: String,
300 + /// raw JSON arguments, pretty-printed by the UI for the expanded view
301 + arguments: String,
302 + },
303 + /// the most recent tool call finished; attach a display-only preview of
304 + /// the tail of its output (the model always gets the full output)
305 + ToolDone {
306 + output_preview: String,
307 + },
308 /// a streamed token fragment of the assistant's reply
309 Delta(String),
310 /// the streamed reply is complete; commit the accumulated buffer
@@ -223,6 +378,8 @@ mod tui {
378 model_picker_items: Vec<ModelPickerItem>,
379 current_model_name: String,
380 tool_calling: bool,
381 + /// `/tools` — expand every tool-call entry (default: collapsed titles)
382 + tools_expanded: bool,
383
384 // ── Model-switch download progress ────────────────────────────────────
385 switching_model_id: Option<String>,
@@ -313,6 +470,7 @@ mod tui {
470 model_picker_items: items,
471 current_model_name,
472 tool_calling,
473 + tools_expanded: false,
474 backend,
475 }
476 }
@@ -777,6 +935,9 @@ mod tui {
935 Plan(Option<bool>),
936 /// Show the effective permission policy for this session.
937 Permissions,
938 + /// Expand or collapse all tool-call entries in the transcript.
939 + /// `Some(true/false)` sets it, `None` flips it. TUI-only.
940 + Tools(Option<bool>),
941 /// Summarize-and-shrink the conversation history on demand.
942 Compact,
943 /// Restore the saved TUI session from disk.
@@ -807,6 +968,7 @@ mod tui {
968 "/whoami" => SlashCommand::Whoami,
969 "/plan" => SlashCommand::Plan(parse_on_off(arg)),
970 "/permissions" => SlashCommand::Permissions,
971 + "/tools" => SlashCommand::Tools(parse_on_off(arg)),
972 "/compact" => SlashCommand::Compact,
973 "/resume" => SlashCommand::Resume,
974 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
@@ -953,7 +1115,7 @@ mod tui {
1115 let mut lines: Vec<Line> = Vec::new();
1116
1117 for msg in &app.messages {
956 - render_chat_message(&mut lines, msg, inner_width as usize);
1118 + render_chat_message(&mut lines, msg, app.tools_expanded);
1119 }
1120
1121 let streamed_visible = app.visible_stream();
@@ -962,8 +1124,10 @@ mod tui {
1124 role: Role::Assistant,
1125 text: streamed_visible,
1126 think_block: None,
1127 + tool_detail: None,
1128 + tool_result: None,
1129 };
966 - render_chat_message(&mut lines, &fake, inner_width as usize);
1130 + render_chat_message(&mut lines, &fake, app.tools_expanded);
1131 if app.blink_on
1132 && let Some(last) = lines.last_mut()
1133 {
@@ -1033,8 +1197,25 @@ mod tui {
1197 frame.render_widget(paragraph.scroll((scroll, 0)), inner);
1198 }
1199
1036 - fn render_chat_message(lines: &mut Vec<Line<'static>>, msg: &ChatMessage, _width: usize) {
1200 + fn render_chat_message(
1201 + lines: &mut Vec<Line<'static>>,
1202 + msg: &ChatMessage,
1203 + tools_expanded: bool,
1204 + ) {
1205 match msg.role {
1206 + Role::Tool => {
1207 + let dim = Style::default()
1208 + .fg(Color::Rgb(132, 132, 145))
1209 + .add_modifier(Modifier::DIM);
1210 + for entry_line in super::tool_entry_lines(
1211 + &msg.text,
1212 + msg.tool_detail.as_deref().unwrap_or(""),
1213 + msg.tool_result.as_deref(),
1214 + tools_expanded,
1215 + ) {
1216 + lines.push(Line::from(Span::styled(format!(" {entry_line}"), dim)));
1217 + }
1218 + }
1219 Role::Banner => {
1220 let palette = [
1221 Color::Red,
@@ -1413,6 +1594,7 @@ mod tui {
1594 /whoami — show the signed-in account\n\
1595 /plan [on|off] — plan mode: research only, no edits or commands\n\
1596 /permissions — show the tool permission policy\n\
1597 + /tools [on|off]— expand or collapse tool-call details\n\
1598 /compact — summarize and shrink conversation history\n\
1599 /resume — restore the saved session from disk\n\
1600 /clear — wipe conversation history\n\
@@ -1489,6 +1671,15 @@ mod tui {
1671 crate::permissions::TUI_SESSION,
1672 )));
1673 }
1674 + SlashCommand::Tools(value) => {
1675 + app.tools_expanded = value.unwrap_or(!app.tools_expanded);
1676 + app.messages
1677 + .push(ChatMessage::system(if app.tools_expanded {
1678 + "Tool calls expanded — /tools off to collapse them."
1679 + } else {
1680 + "Tool calls collapsed — /tools on to expand them."
1681 + }));
1682 + }
1683 SlashCommand::Status => {
1684 let info = engine.as_ref().info().await;
1685 let model = info.model_name.as_deref().unwrap_or("(none)");
@@ -1787,7 +1978,12 @@ mod tui {
1978 tc.arguments.chars().take(120).collect::<String>()
1979 );
1980
1790 - let _ = tx.send(InferenceUpdate::ToolUse(tc.name.clone())).await;
1981 + let _ = tx
1982 + .send(InferenceUpdate::ToolUse {
1983 + name: tc.name.clone(),
1984 + arguments: tc.arguments.clone(),
1985 + })
1986 + .await;
1987
1988 // Permission gate: read-only tools pass straight through; a
1989 // mutating tool consults policy and may pause on the user's
@@ -1841,6 +2037,14 @@ mod tui {
2037 };
2038 log::info!(" ← {} chars", output.len());
2039
2040 + // Display-only preview of the output tail; the model gets the
2041 + // full output through `tool_results` below.
2042 + let _ = tx
2043 + .send(InferenceUpdate::ToolDone {
2044 + output_preview: super::cap_output_preview(&output),
2045 + })
2046 + .await;
2047 +
2048 tool_results.push(ToolResult {
2049 tool_call_id: tc.id.clone(),
2050 content: output,
@@ -2055,8 +2259,22 @@ mod tui {
2259 }
2260 } => {
2261 match update {
2058 - Some(InferenceUpdate::ToolUse(name)) => {
2059 - app.messages.push(ChatMessage::system(format!("🔧 {name}")));
2262 + Some(InferenceUpdate::ToolUse { name, arguments }) => {
2263 + let title = super::tool_title(&name, &arguments);
2264 + let detail = super::pretty_tool_arguments(&arguments);
2265 + app.messages.push(ChatMessage::tool_call(title, detail));
2266 + }
2267 + Some(InferenceUpdate::ToolDone { output_preview }) => {
2268 + // Attach to the most recent tool entry still
2269 + // waiting for its result.
2270 + if let Some(entry) = app
2271 + .messages
2272 + .iter_mut()
2273 + .rev()
2274 + .find(|m| m.role == Role::Tool && m.tool_result.is_none())
2275 + {
2276 + entry.tool_result = Some(output_preview);
2277 + }
2278 }
2279 Some(InferenceUpdate::Delta(delta)) => {
2280 app.push_stream_delta(&delta);
@@ -2290,7 +2508,171 @@ pub use tui::run_with;
2508
2509 #[cfg(test)]
2510 mod tests {
2293 - use super::{parse_rich_text_segments, strip_think_blocks};
2511 + use super::{
2512 + TOOL_RESULT_PREVIEW_MAX, cap_output_preview, parse_rich_text_segments,
2513 + pretty_tool_arguments, strip_think_blocks, tool_entry_lines, tool_title,
2514 + };
2515 +
2516 + // ── tool_title ────────────────────────────────────────────────────────────
2517 +
2518 + #[test]
2519 + fn tool_title_run_command_shows_the_command() {
2520 + assert_eq!(
2521 + tool_title("run_command", r#"{"command":"cargo test"}"#),
2522 + "run_command · cargo test"
2523 + );
2524 + }
2525 +
2526 + #[test]
2527 + fn tool_title_file_tools_show_the_path() {
2528 + for name in [
2529 + "read_file",
2530 + "create_file",
2531 + "edit_file",
2532 + "multi_edit",
2533 + "delete_file",
2534 + ] {
2535 + assert_eq!(
2536 + tool_title(name, r#"{"path":"src/main.rs","old_text":"a"}"#),
2537 + format!("{name} · src/main.rs")
2538 + );
2539 + }
2540 + }
2541 +
2542 + #[test]
2543 + fn tool_title_pattern_tools_show_the_pattern() {
2544 + assert_eq!(
2545 + tool_title("search_files", r#"{"pattern":"fn main","path":"src"}"#),
2546 + "search_files · fn main"
2547 + );
2548 + assert_eq!(
2549 + tool_title("glob", r#"{"pattern":"**/*.rs"}"#),
2550 + "glob · **/*.rs"
2551 + );
2552 + }
2553 +
2554 + #[test]
2555 + fn tool_title_mcp_tools_show_server_and_bare_tool_name() {
2556 + assert_eq!(
2557 + tool_title("mcp__sigit__list_issues", r#"{"repo":"getsigit/sigit"}"#),
2558 + "sigit · list_issues"
2559 + );
2560 + }
2561 +
2562 + #[test]
2563 + fn tool_title_unknown_tool_is_the_name_alone() {
2564 + assert_eq!(
2565 + tool_title("write_todos", r#"{"todos":[{"text":"x"}]}"#),
2566 + "write_todos"
2567 + );
2568 + }
2569 +
2570 + #[test]
2571 + fn tool_title_malformed_json_falls_back_to_the_name() {
2572 + assert_eq!(tool_title("run_command", "{not json"), "run_command");
2573 + assert_eq!(tool_title("edit_file", ""), "edit_file");
2574 + }
2575 +
2576 + #[test]
2577 + fn tool_title_missing_or_non_string_arg_falls_back_to_the_name() {
2578 + assert_eq!(tool_title("run_command", r#"{"other":"x"}"#), "run_command");
2579 + assert_eq!(
2580 + tool_title("run_command", r#"{"command":42}"#),
2581 + "run_command"
2582 + );
2583 + }
2584 +
2585 + #[test]
2586 + fn tool_title_truncates_long_summaries_with_an_ellipsis() {
2587 + let long = "a".repeat(100);
2588 + let title = tool_title("run_command", &format!(r#"{{"command":"{long}"}}"#));
2589 +
2590 + let summary = title.strip_prefix("run_command · ").expect("summary");
2591 + assert_eq!(summary.chars().count(), 60);
2592 + assert!(summary.ends_with('…'));
2593 + }
2594 +
2595 + #[test]
2596 + fn tool_title_collapses_multiline_commands_to_one_line() {
2597 + assert_eq!(
2598 + tool_title("run_command", "{\"command\":\"echo a &&\\n echo b\"}"),
2599 + "run_command · echo a && echo b"
2600 + );
2601 + }
2602 +
2603 + // ── cap_output_preview ────────────────────────────────────────────────────
2604 +
2605 + #[test]
2606 + fn cap_output_preview_passes_short_output_through() {
2607 + assert_eq!(cap_output_preview("hello\nworld"), "hello\nworld");
2608 + }
2609 +
2610 + #[test]
2611 + fn cap_output_preview_keeps_the_tail_and_notes_the_truncation() {
2612 + let output = format!("{}{}", "x".repeat(3000), "the-very-end");
2613 + let preview = cap_output_preview(&output);
2614 +
2615 + assert!(preview.starts_with("… (truncated — showing the last 2000 of 3012 chars)\n"));
2616 + assert!(preview.ends_with("the-very-end"));
2617 + let (_note, tail) = preview.split_once('\n').expect("note line");
2618 + assert_eq!(tail.chars().count(), TOOL_RESULT_PREVIEW_MAX);
2619 + }
2620 +
2621 + // ── pretty_tool_arguments ─────────────────────────────────────────────────
2622 +
2623 + #[test]
2624 + fn pretty_tool_arguments_pretty_prints_valid_json() {
2625 + assert_eq!(
2626 + pretty_tool_arguments(r#"{"command":"ls"}"#),
2627 + "{\n \"command\": \"ls\"\n}"
2628 + );
2629 + }
2630 +
2631 + #[test]
2632 + fn pretty_tool_arguments_shows_malformed_json_raw() {
2633 + assert_eq!(pretty_tool_arguments("{oops"), "{oops");
2634 + }
2635 +
2636 + // ── tool_entry_lines ──────────────────────────────────────────────────────
2637 +
2638 + #[test]
2639 + fn tool_entry_lines_collapsed_is_a_single_title_line() {
2640 + let lines = tool_entry_lines(
2641 + "run_command · cargo test",
2642 + "{\n \"command\": \"cargo test\"\n}",
2643 + Some("ok"),
2644 + false,
2645 + );
2646 + assert_eq!(lines, vec!["▸ 🔧 run_command · cargo test"]);
2647 + }
2648 +
2649 + #[test]
2650 + fn tool_entry_lines_expanded_shows_detail_and_result() {
2651 + let lines = tool_entry_lines(
2652 + "run_command · cargo test",
2653 + "{\n \"command\": \"cargo test\"\n}",
2654 + Some("test ok\ndone"),
2655 + true,
2656 + );
2657 + assert_eq!(
2658 + lines,
2659 + vec![
2660 + "▾ 🔧 run_command · cargo test",
2661 + " {",
2662 + " \"command\": \"cargo test\"",
2663 + " }",
2664 + " result:",
2665 + " test ok",
2666 + " done",
2667 + ]
2668 + );
2669 + }
2670 +
2671 + #[test]
2672 + fn tool_entry_lines_expanded_without_result_omits_the_result_block() {
2673 + let lines = tool_entry_lines("glob · **/*.rs", "{}", None, true);
2674 + assert_eq!(lines, vec!["▾ 🔧 glob · **/*.rs", " {}"]);
2675 + }
2676
2677 #[test]
2678 fn strip_think_blocks_separates_thinking_and_visible_reply() {