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