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();
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>,
372
model_picker_items: items,
373
current_model_name,
374
tool_calling,
375
+ show_thinking: false,
376
backend,
377
}
378
}
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.
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,
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();
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
{
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
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 = [
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(
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\
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)");
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() {
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(