Show model thinking in the TUI with a /thinking toggle

Reasoning models emit their thought process in think tags, and the TUI either hid it behind the spinner while streaming or dumped it as a box on every finished message. Both extremes are wrong: watching the model reason is useful, reading every past thought is noise. While a streamed turn is still inside its think block, the TUI now shows the last three wrapped lines of the reasoning live, dim and italic, under the thinking spinner. Finished replies collapse to a one-line dim indicator with the line count; /thinking (on, off, or bare to flip) expands the full reasoning block above each reply. The tail extraction does its own width-aware word wrap so it is a pure, tested function. Display only: nothing changes in what is sent to the model, saved to sessions, or streamed over ACP, where editors render reasoning themselves. /thinking stays TUI-only, matching how /resume is handled.

paydii committed Jul 5, 2026 at 08:37 UTC 0ed52292e1e1c29c20c597764c6e9d82dfeac9ae
1 file changed +183 -15
src/chat.rs
+183 -15
@@ -37,6 +37,62 @@ pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) {
37 (thinking, remainder.trim().to_string())
38 }
39
40 +/// The live reasoning tail to show while a streaming buffer holds only
41 +/// `<think>` content (no visible reply yet).
42 +///
43 +/// Returns the last `max_lines` lines of the in-progress thinking text after
44 +/// word-wrapping at `width` columns, so the TUI can render them under the
45 +/// "thinking…" label and the user watches the model reason. Returns an empty
46 +/// vec once visible text has arrived (the reply streams instead) or when
47 +/// there is no thinking text yet (plain spinner).
48 +pub(crate) fn streaming_think_tail(raw: &str, width: usize, max_lines: usize) -> Vec<String> {
49 + let (thinking, visible) = strip_think_blocks(raw);
50 + if thinking.is_empty() || !visible.trim().is_empty() || max_lines == 0 {
51 + return Vec::new();
52 + }
53 +
54 + let width = width.max(1);
55 + let mut wrapped: Vec<String> = Vec::new();
56 + for line in thinking.lines() {
57 + let mut current = String::new();
58 + for word in line.split_whitespace() {
59 + // hard-split words wider than the wrap column
60 + let mut word = word;
61 + while word.chars().count() > width {
62 + if !current.is_empty() {
63 + wrapped.push(std::mem::take(&mut current));
64 + }
65 + let head: String = word.chars().take(width).collect();
66 + word = &word[head.len()..];
67 + wrapped.push(head);
68 + }
69 + if current.is_empty() {
70 + current.push_str(word);
71 + } else if current.chars().count() + 1 + word.chars().count() <= width {
72 + current.push(' ');
73 + current.push_str(word);
74 + } else {
75 + wrapped.push(std::mem::take(&mut current));
76 + current.push_str(word);
77 + }
78 + }
79 + if !current.is_empty() {
80 + wrapped.push(current);
81 + }
82 + }
83 +
84 + let skip = wrapped.len().saturating_sub(max_lines);
85 + wrapped.split_off(skip)
86 +}
87 +
88 +/// One-line dim indicator shown above a reply whose reasoning is collapsed,
89 +/// e.g. `· thought for a bit (12 lines) — /thinking to show`.
90 +pub(crate) fn think_indicator(think: &str) -> String {
91 + let count = think.lines().count().max(1);
92 + let noun = if count == 1 { "line" } else { "lines" };
93 + format!("· thought for a bit ({count} {noun}) — /thinking to show")
94 +}
95 +
96 pub(crate) fn parse_rich_text_segments(text: &str) -> Vec<(String, bool)> {
97 let mut segments = Vec::new();
98 let mut current = String::new();
@@ -223,6 +279,9 @@ mod tui {
279 model_picker_items: Vec<ModelPickerItem>,
280 current_model_name: String,
281 tool_calling: bool,
282 + /// `/thinking` — expand the model's reasoning on rendered messages.
283 + /// Display-only: never changes what is sent to the model or saved.
284 + show_thinking: bool,
285
286 // ── Model-switch download progress ────────────────────────────────────
287 switching_model_id: Option<String>,
@@ -313,6 +372,7 @@ mod tui {
372 model_picker_items: items,
373 current_model_name,
374 tool_calling,
375 + show_thinking: false,
376 backend,
377 }
378 }
@@ -777,6 +837,9 @@ mod tui {
837 Plan(Option<bool>),
838 /// Show the effective permission policy for this session.
839 Permissions,
840 + /// Expand or collapse model reasoning on rendered messages.
841 + /// `Some(true/false)` sets it, `None` flips it.
842 + Thinking(Option<bool>),
843 /// Summarize-and-shrink the conversation history on demand.
844 Compact,
845 /// Restore the saved TUI session from disk.
@@ -807,6 +870,7 @@ mod tui {
870 "/whoami" => SlashCommand::Whoami,
871 "/plan" => SlashCommand::Plan(parse_on_off(arg)),
872 "/permissions" => SlashCommand::Permissions,
873 + "/thinking" => SlashCommand::Thinking(parse_on_off(arg)),
874 "/compact" => SlashCommand::Compact,
875 "/resume" => SlashCommand::Resume,
876 "/exit" | "/quit" | "/q" => SlashCommand::Exit,
@@ -953,7 +1017,7 @@ mod tui {
1017 let mut lines: Vec<Line> = Vec::new();
1018
1019 for msg in &app.messages {
956 - render_chat_message(&mut lines, msg, inner_width as usize);
1020 + render_chat_message(&mut lines, msg, inner_width as usize, app.show_thinking);
1021 }
1022
1023 let streamed_visible = app.visible_stream();
@@ -963,7 +1027,7 @@ mod tui {
1027 text: streamed_visible,
1028 think_block: None,
1029 };
966 - render_chat_message(&mut lines, &fake, inner_width as usize);
1030 + render_chat_message(&mut lines, &fake, inner_width as usize, app.show_thinking);
1031 if app.blink_on
1032 && let Some(last) = lines.last_mut()
1033 {
@@ -977,6 +1041,17 @@ mod tui {
1041 format!(" {} thinking…", app.thinking_frame()),
1042 Style::default().fg(Color::DarkGray),
1043 )));
1044 + // While the stream buffer holds only reasoning, show its live tail
1045 + // under the label so the user watches the model think.
1046 + let tail_width = (inner_width as usize).saturating_sub(4).max(8);
1047 + for tail_line in super::streaming_think_tail(&app.stream_buf, tail_width, 3) {
1048 + lines.push(Line::from(Span::styled(
1049 + format!(" {tail_line}"),
1050 + Style::default()
1051 + .fg(Color::DarkGray)
1052 + .add_modifier(Modifier::DIM | Modifier::ITALIC),
1053 + )));
1054 + }
1055 } else if app.switching_model {
1056 // Once the weights have fully landed on disk, swap the spinner for a
1057 // checkmark so it's clear the download finished and we're now loading
@@ -1033,7 +1108,12 @@ mod tui {
1108 frame.render_widget(paragraph.scroll((scroll, 0)), inner);
1109 }
1110
1036 - fn render_chat_message(lines: &mut Vec<Line<'static>>, msg: &ChatMessage, _width: usize) {
1111 + fn render_chat_message(
1112 + lines: &mut Vec<Line<'static>>,
1113 + msg: &ChatMessage,
1114 + _width: usize,
1115 + show_thinking: bool,
1116 + ) {
1117 match msg.role {
1118 Role::Banner => {
1119 let palette = [
@@ -1098,20 +1178,33 @@ mod tui {
1178 }
1179 Role::Assistant => {
1180 if let Some(ref think) = msg.think_block {
1101 - lines.push(Line::from(Span::styled(
1102 - " ┌ thinking ".to_string(),
1103 - Style::default().fg(Color::DarkGray),
1104 - )));
1105 - for think_line in think.split('\n') {
1181 + if show_thinking {
1182 + // /thinking on — the full reasoning, dim italic, with a
1183 + // blank line separating it from the answer below.
1184 lines.push(Line::from(Span::styled(
1107 - format!(" │ {think_line}"),
1108 - Style::default().fg(Color::DarkGray),
1185 + " · thinking".to_string(),
1186 + Style::default()
1187 + .fg(Color::DarkGray)
1188 + .add_modifier(Modifier::DIM),
1189 + )));
1190 + for think_line in think.split('\n') {
1191 + lines.push(Line::from(Span::styled(
1192 + format!(" {think_line}"),
1193 + Style::default()
1194 + .fg(Color::DarkGray)
1195 + .add_modifier(Modifier::DIM | Modifier::ITALIC),
1196 + )));
1197 + }
1198 + lines.push(Line::from(""));
1199 + } else {
1200 + // collapsed — one dim line noting reasoning exists
1201 + lines.push(Line::from(Span::styled(
1202 + format!(" {}", super::think_indicator(think)),
1203 + Style::default()
1204 + .fg(Color::DarkGray)
1205 + .add_modifier(Modifier::DIM),
1206 )));
1207 }
1111 - lines.push(Line::from(Span::styled(
1112 - " └─────────".to_string(),
1113 - Style::default().fg(Color::DarkGray),
1114 - )));
1208 }
1209
1210 let prefix = Span::styled(
@@ -1413,6 +1506,7 @@ mod tui {
1506 /whoami — show the signed-in account\n\
1507 /plan [on|off] — plan mode: research only, no edits or commands\n\
1508 /permissions — show the tool permission policy\n\
1509 + /thinking [on|off] — expand or collapse model reasoning\n\
1510 /compact — summarize and shrink conversation history\n\
1511 /resume — restore the saved session from disk\n\
1512 /clear — wipe conversation history\n\
@@ -1489,6 +1583,15 @@ mod tui {
1583 crate::permissions::TUI_SESSION,
1584 )));
1585 }
1586 + SlashCommand::Thinking(value) => {
1587 + app.show_thinking = value.unwrap_or(!app.show_thinking);
1588 + app.messages.push(ChatMessage::system(if app.show_thinking {
1589 + "Thinking display ON — model reasoning is shown above each reply."
1590 + } else {
1591 + "Thinking display OFF — replies show a one-line indicator; \
1592 + /thinking to expand."
1593 + }));
1594 + }
1595 SlashCommand::Status => {
1596 let info = engine.as_ref().info().await;
1597 let model = info.model_name.as_deref().unwrap_or("(none)");
@@ -2290,7 +2393,9 @@ pub use tui::run_with;
2393
2394 #[cfg(test)]
2395 mod tests {
2293 - use super::{parse_rich_text_segments, strip_think_blocks};
2396 + use super::{
2397 + parse_rich_text_segments, streaming_think_tail, strip_think_blocks, think_indicator,
2398 + };
2399
2400 #[test]
2401 fn strip_think_blocks_separates_thinking_and_visible_reply() {
@@ -2319,6 +2424,69 @@ mod tests {
2424 assert_eq!(visible, "No hidden reasoning here.");
2425 }
2426
2427 + #[test]
2428 + fn streaming_think_tail_shows_last_lines_of_unclosed_think_buffer() {
2429 + // the common mid-stream shape: `<think>` opened, not yet closed
2430 + let raw = "<think>First thought.\nSecond thought.\nThird thought.\nFourth thought.";
2431 + let tail = streaming_think_tail(raw, 40, 3);
2432 +
2433 + assert_eq!(
2434 + tail,
2435 + vec!["Second thought.", "Third thought.", "Fourth thought."]
2436 + );
2437 + }
2438 +
2439 + #[test]
2440 + fn streaming_think_tail_wraps_long_lines_at_width() {
2441 + let raw = "<think>one two three four five six seven";
2442 + let tail = streaming_think_tail(raw, 10, 3);
2443 +
2444 + // wrapped at 10 cols: ["one two", "three four", "five six", "seven"];
2445 + // the tail keeps only the last 3
2446 + assert_eq!(tail, vec!["three four", "five six", "seven"]);
2447 + }
2448 +
2449 + #[test]
2450 + fn streaming_think_tail_is_empty_once_visible_text_arrives() {
2451 + let raw = "<think>hidden reasoning</think>The answer is 42.";
2452 + assert!(streaming_think_tail(raw, 40, 3).is_empty());
2453 + }
2454 +
2455 + #[test]
2456 + fn streaming_think_tail_is_empty_for_only_think_closed_but_no_visible_yet() {
2457 + // closed think, visible not started: still show the reasoning tail
2458 + let raw = "<think>done reasoning</think>";
2459 + assert_eq!(streaming_think_tail(raw, 40, 3), vec!["done reasoning"]);
2460 + }
2461 +
2462 + #[test]
2463 + fn streaming_think_tail_is_empty_for_empty_or_plain_buffers() {
2464 + assert!(streaming_think_tail("", 40, 3).is_empty());
2465 + assert!(streaming_think_tail("plain visible text", 40, 3).is_empty());
2466 + assert!(streaming_think_tail("<think>", 40, 3).is_empty());
2467 + }
2468 +
2469 + #[test]
2470 + fn streaming_think_tail_hard_splits_words_wider_than_the_wrap_column() {
2471 + let raw = "<think>abcdefghijkl";
2472 + assert_eq!(
2473 + streaming_think_tail(raw, 5, 3),
2474 + vec!["abcde", "fghij", "kl"]
2475 + );
2476 + }
2477 +
2478 + #[test]
2479 + fn think_indicator_counts_lines_and_pluralizes() {
2480 + assert_eq!(
2481 + think_indicator("only one line of reasoning"),
2482 + "· thought for a bit (1 line) — /thinking to show"
2483 + );
2484 + assert_eq!(
2485 + think_indicator("a\nb\nc"),
2486 + "· thought for a bit (3 lines) — /thinking to show"
2487 + );
2488 + }
2489 +
2490 #[test]
2491 fn parse_rich_text_segments_marks_bold_runs() {
2492 let segments = parse_rich_text_segments(