返回 CodeWhale
thinking.rs
根目录 / crates / tui / src / tui / history / thinking.rs
1 //! Rendering for reasoning/thinking transcript cells.
2
3 use ratatui::style::{Color, Modifier, Style};
4 use ratatui::text::{Line, Span};
5
6 use crate::tui::markdown_render;
7 use codewhale_palette as palette;
8
9 /// Reasoning header opener. Replaces the spinner glyph on thinking cells —
10 /// reasoning is a slow exhale, not a tool spin.
11 pub(super) const REASONING_OPENER: &str = "\u{2026}"; // …
12 /// Reasoning body left rail. Dashed (`╎`) instead of the solid `▏` block to
13 /// visually separate reasoning from message body and tool output.
14 pub(super) const REASONING_RAIL: &str = "\u{254E} "; // ╎ + space
15 /// Trailing-line cursor on streaming reasoning. Anchored to the live colour
16 /// so the user sees where new tokens land.
17 pub(super) const REASONING_CURSOR: &str = "\u{258E}"; // ▎
18
19 const THINKING_SUMMARY_LINE_LIMIT: usize = 4;
20 /// Completed collapsed thought: a short lede, not a ten-line dump.
21 /// Grok's finished thought is header-only; we keep two lines so a one-step
22 /// thought is still readable without forcing an expand.
23 const THINKING_COMPLETED_PREVIEW_LINE_LIMIT: usize = 2;
24 const THINKING_STREAMING_PREVIEW_LINE_LIMIT: usize = 12;
25
26 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27 enum ThinkingVisualState {
28 Live,
29 Done,
30 Idle,
31 }
32
33 #[cfg(test)]
34 #[must_use]
35 pub fn extract_reasoning_summary(text: &str) -> Option<String> {
36 extract_explicit_reasoning_summary(text).or_else(|| {
37 let fallback = text.trim();
38 if fallback.is_empty() {
39 None
40 } else {
41 Some(fallback.to_string())
42 }
43 })
44 }
45
46 fn extract_explicit_reasoning_summary(text: &str) -> Option<String> {
47 let mut lines = text.lines().peekable();
48 while let Some(line) = lines.next() {
49 let trimmed = line.trim();
50 if trimmed.to_lowercase().starts_with("summary") {
51 let mut summary = String::new();
52 if let Some((_, rest)) = trimmed.split_once(':')
53 && !rest.trim().is_empty()
54 {
55 summary.push_str(rest.trim());
56 summary.push('\n');
57 }
58 while let Some(next) = lines.peek() {
59 let next_trimmed = next.trim();
60 if next_trimmed.is_empty() {
61 break;
62 }
63 if next_trimmed.starts_with('#') || next_trimmed.starts_with("**") {
64 break;
65 }
66 summary.push_str(next_trimmed);
67 summary.push('\n');
68 lines.next();
69 }
70 let summary = summary.trim().to_string();
71 return if summary.is_empty() {
72 None
73 } else {
74 Some(summary)
75 };
76 }
77 }
78 None
79 }
80
81 pub(super) fn render_thinking(
82 content: &str,
83 width: u16,
84 streaming: bool,
85 duration_secs: Option<f32>,
86 collapsed: bool,
87 low_motion: bool,
88 ) -> Vec<Line<'static>> {
89 render_thinking_with_analysis(
90 content,
91 width,
92 streaming,
93 duration_secs,
94 collapsed,
95 low_motion,
96 true,
97 )
98 .0
99 }
100
101 pub(crate) fn render_thinking_with_analysis(
102 content: &str,
103 width: u16,
104 streaming: bool,
105 duration_secs: Option<f32>,
106 collapsed: bool,
107 low_motion: bool,
108 highlight: bool,
109 ) -> (Vec<Line<'static>>, bool) {
110 render_thinking_with_preview_limit(
111 content,
112 width,
113 streaming,
114 duration_secs,
115 collapsed,
116 low_motion,
117 highlight,
118 0,
119 THINKING_COMPLETED_PREVIEW_LINE_LIMIT,
120 )
121 }
122
123 #[allow(clippy::too_many_arguments)]
124 pub(crate) fn render_thinking_with_preview_limit(
125 content: &str,
126 width: u16,
127 streaming: bool,
128 duration_secs: Option<f32>,
129 collapsed: bool,
130 low_motion: bool,
131 highlight: bool,
132 preview_extra_lines: usize,
133 completed_preview_lines: usize,
134 ) -> (Vec<Line<'static>>, bool) {
135 let state = thinking_visual_state(streaming, duration_secs);
136 let style = thinking_style();
137 // 12% reasoning surface tint over the app ink — the only deliberately
138 // warm element in the transcript. Dropped on Ansi-16 terminals where the
139 // tint would distort the named palette.
140 let depth = cached_color_depth();
141 let body_bg = palette::reasoning_surface_tint(depth);
142 let body_style = match (highlight, body_bg) {
143 (true, Some(bg)) => style.italic().bg(bg),
144 (_, None) | (false, Some(_)) => style.italic(),
145 };
146 let mut lines = Vec::new();
147
148 // Header: `…` opener (replaces the spinner; reasoning isn't a tool, it's
149 // a slow exhale) followed by the reasoning label and live status.
150 let mut header_spans = vec![
151 Span::styled(
152 format!("{REASONING_OPENER} "),
153 Style::default().fg(thinking_state_accent(state)),
154 ),
155 Span::styled("reasoning", thinking_title_style()),
156 ];
157 header_spans.push(Span::styled(" ", Style::default()));
158 header_spans.push(Span::styled(
159 thinking_status_label(state),
160 thinking_status_style(state),
161 ));
162 if let Some(dur) = duration_secs {
163 header_spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
164 header_spans.push(Span::styled(
165 crate::elapsed::format_elapsed_ms((dur * 1000.0) as u64),
166 thinking_meta_style(),
167 ));
168 }
169 lines.push(Line::from(header_spans));
170
171 let content_width = width.saturating_sub(3).max(1);
172 // #6196: compute only the projection being shown. The previous order ran
173 // the collapsed preview render first and threw it away whenever the body
174 // was expanded — a full extra body render per streaming beat.
175 let (rendered, expandable) = if collapsed {
176 collapsed_thinking_body(
177 content,
178 width,
179 streaming,
180 body_style,
181 preview_extra_lines,
182 completed_preview_lines,
183 )
184 } else if content.trim().is_empty() {
185 (Vec::new(), false)
186 } else {
187 let body = markdown_render::render_markdown(content, content_width, body_style);
188 // The collapse affordance mirrors the collapsed preview's "more than
189 // the preview would show" test, derived from this single render
190 // instead of rendering the preview too: streaming content outgrows
191 // the streaming preview; settled content shows more than the
192 // completed preview, or differs from its explicit summary.
193 let expandable = if streaming {
194 body.len() > THINKING_STREAMING_PREVIEW_LINE_LIMIT.saturating_add(preview_extra_lines)
195 } else {
196 extract_explicit_reasoning_summary(content).is_some_and(|summary| {
197 summary.trim() != content.trim() || body.len() > THINKING_SUMMARY_LINE_LIMIT
198 }) || body.len() > completed_preview_lines.saturating_add(preview_extra_lines)
199 };
200 (body, expandable)
201 };
202
203 let rail_style = Style::default().fg(thinking_state_accent(state));
204 let cursor_style = Style::default().fg(palette::ACCENT_REASONING_LIVE);
205
206 if rendered.is_empty() && streaming {
207 let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)];
208 spans.push(Span::styled("reasoning...", body_style.italic()));
209 if !low_motion {
210 spans.push(Span::styled(format!(" {REASONING_CURSOR}"), cursor_style));
211 }
212 lines.push(Line::from(spans));
213 }
214
215 let last_idx = rendered.len().saturating_sub(1);
216 for (idx, line) in rendered.into_iter().enumerate() {
217 let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)];
218 spans.extend(line.spans);
219 // Mark only the live tail; styling every line would churn the block.
220 if streaming && !low_motion && idx == last_idx {
221 spans.push(Span::styled(format!(" {REASONING_CURSOR}"), cursor_style));
222 }
223 lines.push(Line::from(spans));
224 }
225
226 if collapsed && expandable {
227 lines.push(Line::from(vec![
228 Span::styled(REASONING_RAIL.to_string(), rail_style),
229 Span::styled(
230 REASONING_OPENER,
231 Style::default().fg(palette::TEXT_MUTED).italic(),
232 ),
233 ]));
234 }
235
236 (lines, expandable)
237 }
238
239 fn collapsed_thinking_body(
240 content: &str,
241 width: u16,
242 streaming: bool,
243 style: Style,
244 preview_extra_lines: usize,
245 completed_preview_lines: usize,
246 ) -> (Vec<Line<'static>>, bool) {
247 let (body_text, without_explicit_summary): (std::borrow::Cow<'_, str>, bool) = if streaming {
248 // #861 RC4 / #1324: an in-flight block has no meaningful completed
249 // summary. Render raw content; the limit below keeps its newest lines.
250 (std::borrow::Cow::Borrowed(content), false)
251 } else {
252 match extract_explicit_reasoning_summary(content) {
253 Some(summary) => (std::borrow::Cow::Owned(summary), false),
254 None => (std::borrow::Cow::Borrowed(content), true),
255 }
256 };
257 let limit = if streaming {
258 THINKING_STREAMING_PREVIEW_LINE_LIMIT.saturating_add(preview_extra_lines)
259 } else if without_explicit_summary {
260 completed_preview_lines.saturating_add(preview_extra_lines)
261 } else {
262 THINKING_SUMMARY_LINE_LIMIT
263 };
264 // #6196: the streaming preview keeps only the newest `limit` rendered
265 // lines, so rendering the whole body every beat made streaming cost grow
266 // with message size. Render a self-contained tail of the source instead;
267 // the settled (`!streaming`) path still renders everything once per
268 // revision, which the transcript cache already amortizes.
269 let render_source: &str = if streaming {
270 streaming_preview_tail_source(&body_text, limit.saturating_add(1))
271 } else {
272 &body_text
273 };
274 // #4146/#4148 used to scrub snake_case here. That rule could not tell
275 // CodeWhale identifiers from user identifiers: paths, env vars, and
276 // module names became bare ellipses while the full body remained one
277 // keypress away. Keep the default view readable; do not revive the scrub.
278 let mut lines = if render_source.trim().is_empty() {
279 Vec::new()
280 } else {
281 markdown_render::render_markdown(render_source, width.saturating_sub(3).max(1), style)
282 };
283 let truncated = lines.len() > limit;
284 if truncated {
285 if streaming {
286 // Follow the live cursor: discard the head, not the newest lines.
287 lines.drain(0..lines.len() - limit);
288 } else {
289 lines.truncate(limit);
290 }
291 }
292 let meaningful = truncated || (!streaming && body_text.trim() != content.trim());
293 (lines, meaningful)
294 }
295
296 /// A trailing slice of the streaming reasoning body that renders
297 /// independently of the lines above it (#6196).
298 ///
299 /// The slice cannot start mid-construct: a cut inside a fenced code block
300 /// would re-classify its lines as paragraphs, and a cut inside a table group
301 /// would re-render it as a fresh table. One forward pass mirrors the parser's
302 /// own fence rule (`push_parsed_line`) to learn, per line boundary, whether
303 /// the boundary sits inside an open fence; the start is then moved back past
304 /// any construct it would split. Collecting `min_source_lines` complete
305 /// lines is enough for the caller's purposes: every source line renders to
306 /// one or more rows, so `limit + 1` source lines always yield more than
307 /// `limit` rendered rows and the "truncated" verdict survives.
308 fn streaming_preview_tail_source(body: &str, min_source_lines: usize) -> &str {
309 // (byte offset, whether the boundary above this line is inside an open
310 // fence). One entry per line; cheap next to the render it bounds.
311 let mut lines: Vec<(usize, bool)> = Vec::new();
312 let mut open_fence_len: Option<usize> = None;
313 let mut offset = 0usize;
314 for piece in body.split_inclusive('\n') {
315 let raw_line = piece
316 .strip_suffix('\n')
317 .map_or(piece, |line| line.strip_suffix('\r').unwrap_or(line));
318 lines.push((offset, open_fence_len.is_some()));
319 let trimmed = raw_line.trim_start();
320 let fence_len = trimmed.chars().take_while(|c| *c == '`').count();
321 if fence_len >= 3 {
322 match open_fence_len {
323 Some(open) if fence_len >= open && trimmed[fence_len..].trim().is_empty() => {
324 open_fence_len = None;
325 }
326 None => open_fence_len = Some(fence_len),
327 Some(_) => {}
328 }
329 }
330 offset += piece.len();
331 }
332
333 if lines.len() <= min_source_lines {
334 // Fewer lines than the window needs: render the whole body.
335 return body;
336 }
337 // Walk the desired start back to a boundary that splits no construct:
338 // never inside an open fence (that boundary's line is code content) and
339 // never mid-table (a table group is a run of `|`-prefixed lines).
340 let mut index = lines.len() - min_source_lines;
341 while index > 0 {
342 let (line_offset, inside_before) = lines[index];
343 let line = body[line_offset..].lines().next().unwrap_or("");
344 if !inside_before && !line.trim_start().starts_with('|') {
345 break;
346 }
347 index -= 1;
348 }
349 &body[lines[index].0..]
350 }
351
352 pub(super) fn render_hidden_thinking_activity(
353 _width: u16,
354 duration_secs: Option<f32>,
355 low_motion: bool,
356 ) -> Vec<Line<'static>> {
357 let state = ThinkingVisualState::Live;
358 let mut header_spans = vec![
359 Span::styled(
360 format!("{REASONING_OPENER} "),
361 Style::default().fg(thinking_state_accent(state)),
362 ),
363 // A hidden live block needs one receipt, not stacked variants of the
364 // same state ("reasoning live" plus "reasoning hidden; working").
365 Span::styled("reasoning hidden", thinking_title_style()),
366 ];
367 if let Some(dur) = duration_secs {
368 header_spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
369 header_spans.push(Span::styled(
370 crate::elapsed::format_elapsed_ms((dur * 1000.0) as u64),
371 thinking_meta_style(),
372 ));
373 }
374 if !low_motion {
375 header_spans.push(Span::styled(
376 format!(" {REASONING_CURSOR}"),
377 Style::default().fg(palette::ACCENT_REASONING_LIVE),
378 ));
379 }
380 vec![Line::from(header_spans)]
381 }
382
383 fn thinking_style() -> Style {
384 Style::default().fg(palette::TEXT_REASONING)
385 }
386
387 fn thinking_visual_state(streaming: bool, duration_secs: Option<f32>) -> ThinkingVisualState {
388 if streaming {
389 ThinkingVisualState::Live
390 } else if duration_secs.is_some() {
391 ThinkingVisualState::Done
392 } else {
393 ThinkingVisualState::Idle
394 }
395 }
396
397 fn thinking_status_label(state: ThinkingVisualState) -> &'static str {
398 match state {
399 ThinkingVisualState::Live => "live",
400 ThinkingVisualState::Done => "done",
401 ThinkingVisualState::Idle => "idle",
402 }
403 }
404
405 fn thinking_title_style() -> Style {
406 Style::default()
407 .fg(palette::TEXT_SOFT)
408 .add_modifier(Modifier::BOLD)
409 }
410
411 fn thinking_status_style(state: ThinkingVisualState) -> Style {
412 Style::default().fg(match state {
413 ThinkingVisualState::Live => palette::ACCENT_REASONING_LIVE,
414 ThinkingVisualState::Done => palette::TEXT_DIM,
415 ThinkingVisualState::Idle => palette::TEXT_DIM,
416 })
417 }
418
419 fn thinking_meta_style() -> Style {
420 Style::default().fg(palette::TEXT_DIM)
421 }
422
423 fn thinking_state_accent(state: ThinkingVisualState) -> Color {
424 match state {
425 ThinkingVisualState::Live => palette::ACCENT_REASONING_LIVE,
426 ThinkingVisualState::Done => palette::TEXT_DIM,
427 ThinkingVisualState::Idle => palette::TEXT_DIM,
428 }
429 }
430
431 /// Once-initialised colour depth for the terminal session. Avoids re-reading
432 /// `COLORTERM` / `TERM` env vars on every frame.
433 static COLOR_DEPTH: std::sync::OnceLock<palette::ColorDepth> = std::sync::OnceLock::new();
434
435 pub(super) fn cached_color_depth() -> palette::ColorDepth {
436 *COLOR_DEPTH.get_or_init(palette::ColorDepth::detect)
437 }
438
439 #[cfg(test)]
440 mod tests {
441 use super::*;
442
443 fn joined_text(lines: &[Line<'static>]) -> Vec<String> {
444 lines
445 .iter()
446 .map(|line| {
447 line.spans
448 .iter()
449 .map(|span| span.content.as_ref())
450 .collect::<String>()
451 })
452 .collect()
453 }
454
455 #[test]
456 fn tail_source_extends_back_past_fences_and_tables() {
457 // Short bodies render whole.
458 assert_eq!(
459 streaming_preview_tail_source("one\ntwo\n", 12),
460 "one\ntwo\n"
461 );
462
463 let mut fenced = String::from("```rust\n");
464 for i in 0..30 {
465 fenced.push_str(&format!("let v{i} = {i};\n"));
466 }
467 fenced.push_str("```\nafter the block\n");
468 // A 2-line window would start at the closing fence (which parses as
469 // an opener when orphaned); the slice must start at the real fence
470 // opener so the code lines keep their classification.
471 let slice = streaming_preview_tail_source(&fenced, 2);
472 assert!(slice.starts_with("```rust"));
473 assert!(slice.ends_with("after the block\n"));
474
475 // A table group must not be split either.
476 let table = "intro\n| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
477 assert_eq!(streaming_preview_tail_source(table, 2), table);
478
479 // An unterminated fence owns everything after its opener; the slice
480 // must extend back to the opener, not cut inside the block.
481 let open = "```\ncode a\ncode b\ncode c\n";
482 let slice = streaming_preview_tail_source(open, 2);
483 assert!(slice.starts_with("```\ncode a"));
484 }
485
486 #[test]
487 fn collapsed_streaming_preview_renders_only_the_newest_lines() {
488 let mut body = String::new();
489 for i in 0..40 {
490 body.push_str(&format!("head marker {i}\n"));
491 }
492 for i in 0..20 {
493 body.push_str(&format!("tail marker {i}\n"));
494 }
495 let (lines, expandable) = render_thinking_with_preview_limit(
496 &body,
497 100,
498 true,
499 None,
500 true,
501 false,
502 false,
503 0,
504 THINKING_COMPLETED_PREVIEW_LINE_LIMIT,
505 );
506 assert!(expandable, "a long streaming body must offer expand");
507 // header + 12 preview lines + the expand affordance row
508 assert_eq!(lines.len(), 1 + THINKING_STREAMING_PREVIEW_LINE_LIMIT + 1);
509 let text = joined_text(&lines);
510 assert!(text.iter().any(|t| t.contains("tail marker 19")));
511 assert!(text.iter().any(|t| t.contains("tail marker 8")));
512 assert!(
513 !text.iter().any(|t| t.contains("tail marker 7")),
514 "the window must drop the head: {text:?}"
515 );
516 assert!(!text.iter().any(|t| t.contains("head marker 39")));
517 }
518
519 #[test]
520 fn collapsed_streaming_preview_keeps_open_fence_classification() {
521 let mut body = String::from("```\n");
522 for i in 0..40 {
523 body.push_str(&format!("code line {i}\n"));
524 }
525 let (lines, _) = render_thinking_with_preview_limit(
526 &body,
527 100,
528 true,
529 None,
530 true,
531 false,
532 false,
533 0,
534 THINKING_COMPLETED_PREVIEW_LINE_LIMIT,
535 );
536 // Code rows carry the two-space code prefix after the rail; if the
537 // tail slice started inside the open fence they would render as
538 // paragraphs and lose it.
539 let text = joined_text(&lines);
540 let code_prefix = format!("{REASONING_RAIL} ");
541 assert!(
542 text.iter().any(|t| t.starts_with(&code_prefix)),
543 "visible code rows must keep the code prefix: {text:?}"
544 );
545 }
546
547 #[test]
548 fn expanded_streaming_thinking_parses_the_body_once() {
549 // #6196: the expanded path used to run the collapsed preview render
550 // first and throw it away — two full body renders per beat.
551 let mut body = String::from("```\n");
552 for i in 0..40 {
553 body.push_str(&format!("expanded line {i}\n"));
554 }
555 markdown_render::reset_parse_invocation_count();
556 let (lines, expandable) = render_thinking_with_preview_limit(
557 &body,
558 100,
559 true,
560 None,
561 false,
562 false,
563 false,
564 0,
565 THINKING_COMPLETED_PREVIEW_LINE_LIMIT,
566 );
567 assert_eq!(
568 markdown_render::parse_invocation_count(),
569 1,
570 "the expanded view must render the body exactly once"
571 );
572 assert!(expandable);
573 assert!(lines.len() > THINKING_STREAMING_PREVIEW_LINE_LIMIT);
574 }
575 }
576
576 lines RUST