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.
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 {
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 {
243
role: Role::User,
244
text: text.into(),
245
think_block: None,
246
+ tool_detail: None,
247
+ tool_result: None,
248
}
249
}
250
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
265
role: Role::System,
266
text: text.into(),
267
think_block: None,
268
+ tool_detail: None,
269
+ tool_result: None,
270
}
271
}
272
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
}
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
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>,
470
model_picker_items: items,
471
current_model_name,
472
tool_calling,
473
+ tools_expanded: false,
474
backend,
475
}
476
}
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.
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,
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();
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
{
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,
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\
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)");
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
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,
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);
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() {