返回 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::palette;
7 use crate::tui::markdown_render;
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 const THINKING_COMPLETED_PREVIEW_LINE_LIMIT: usize = 10;
21 const THINKING_STREAMING_PREVIEW_LINE_LIMIT: usize = 12;
22
23 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
24 enum ThinkingVisualState {
25 Live,
26 Done,
27 Idle,
28 }
29
30 #[allow(dead_code)] // Kept for compatibility/tests; live view uses explicit summaries only.
31 #[must_use]
32 pub fn extract_reasoning_summary(text: &str) -> Option<String> {
33 extract_explicit_reasoning_summary(text).or_else(|| {
34 let fallback = text.trim();
35 if fallback.is_empty() {
36 None
37 } else {
38 Some(fallback.to_string())
39 }
40 })
41 }
42
43 fn extract_explicit_reasoning_summary(text: &str) -> Option<String> {
44 let mut lines = text.lines().peekable();
45 while let Some(line) = lines.next() {
46 let trimmed = line.trim();
47 if trimmed.to_lowercase().starts_with("summary") {
48 let mut summary = String::new();
49 if let Some((_, rest)) = trimmed.split_once(':')
50 && !rest.trim().is_empty()
51 {
52 summary.push_str(rest.trim());
53 summary.push('\n');
54 }
55 while let Some(next) = lines.peek() {
56 let next_trimmed = next.trim();
57 if next_trimmed.is_empty() {
58 break;
59 }
60 if next_trimmed.starts_with('#') || next_trimmed.starts_with("**") {
61 break;
62 }
63 summary.push_str(next_trimmed);
64 summary.push('\n');
65 lines.next();
66 }
67 let summary = summary.trim().to_string();
68 return if summary.is_empty() {
69 None
70 } else {
71 Some(summary)
72 };
73 }
74 }
75 None
76 }
77
78 pub(super) fn render_thinking(
79 content: &str,
80 width: u16,
81 streaming: bool,
82 duration_secs: Option<f32>,
83 collapsed: bool,
84 low_motion: bool,
85 ) -> Vec<Line<'static>> {
86 render_thinking_with_highlight(
87 content,
88 width,
89 streaming,
90 duration_secs,
91 collapsed,
92 low_motion,
93 true,
94 )
95 }
96
97 pub(crate) fn render_thinking_with_highlight(
98 content: &str,
99 width: u16,
100 streaming: bool,
101 duration_secs: Option<f32>,
102 collapsed: bool,
103 low_motion: bool,
104 highlight: bool,
105 ) -> Vec<Line<'static>> {
106 let state = thinking_visual_state(streaming, duration_secs);
107 let style = thinking_style();
108 // 12% reasoning surface tint over the app ink — the only deliberately
109 // warm element in the transcript. Dropped on Ansi-16 terminals where the
110 // tint would distort the named palette.
111 let depth = cached_color_depth();
112 let body_bg = palette::reasoning_surface_tint(depth);
113 let body_style = match (highlight, body_bg) {
114 (true, Some(bg)) => style.italic().bg(bg),
115 (_, None) | (false, Some(_)) => style.italic(),
116 };
117 let mut lines = Vec::new();
118
119 // Header: `…` opener (replaces the spinner; reasoning isn't a tool, it's
120 // a slow exhale) followed by the reasoning label and live status.
121 let mut header_spans = vec![
122 Span::styled(
123 format!("{REASONING_OPENER} "),
124 Style::default().fg(thinking_state_accent(state)),
125 ),
126 Span::styled("reasoning", thinking_title_style()),
127 ];
128 header_spans.push(Span::styled(" ", Style::default()));
129 header_spans.push(Span::styled(
130 thinking_status_label(state),
131 thinking_status_style(state),
132 ));
133 if let Some(dur) = duration_secs {
134 header_spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
135 header_spans.push(Span::styled(
136 crate::elapsed::format_elapsed_ms((dur * 1000.0) as u64),
137 thinking_meta_style(),
138 ));
139 }
140 lines.push(Line::from(header_spans));
141
142 let content_width = width.saturating_sub(3).max(1);
143 let mut collapsed_without_explicit_summary = false;
144 let body_text = if collapsed {
145 if streaming {
146 // #861 RC4 / #1324: during streaming we don't yet have a
147 // completed reasoning block, so `extract_reasoning_summary`
148 // is meaningless. Show the raw content and let the
149 // truncation logic below keep the *last* `LIMIT` lines so
150 // the user sees the model's most recent thinking instead of
151 // staring at an empty placeholder.
152 content.to_string()
153 } else {
154 match extract_explicit_reasoning_summary(content) {
155 Some(summary) => summary,
156 None => {
157 collapsed_without_explicit_summary = true;
158 content.to_string()
159 }
160 }
161 }
162 } else {
163 content.to_string()
164 };
165 // #4146/#4148 used to scrub snake_case tokens out of the collapsed
166 // reasoning here, to keep CodeWhale's own internals out of the transcript.
167 // Removed: the rule could not tell our identifiers from the user's, and in
168 // a coding harness the user's dominate. It rendered `short_dated_radar.py`
169 // as `….py`, `data/market_data/` as `data/…/`, and every env var and
170 // module name as a bare `…`, which made the default reasoning view
171 // unreadable. It also protected nothing — the full body was always one
172 // keypress away on Space/Ctrl+O — so the only thing it reliably did was
173 // damage the surface people actually read.
174 let mut rendered = if body_text.trim().is_empty() {
175 Vec::new()
176 } else {
177 markdown_render::render_markdown(&body_text, content_width, body_style)
178 };
179 let mut truncated = false;
180 let line_limit = if streaming {
181 THINKING_STREAMING_PREVIEW_LINE_LIMIT
182 } else if collapsed_without_explicit_summary {
183 THINKING_COMPLETED_PREVIEW_LINE_LIMIT
184 } else {
185 THINKING_SUMMARY_LINE_LIMIT
186 };
187 if collapsed && rendered.len() > line_limit {
188 if streaming {
189 // Drop the *head* during streaming so the visible window
190 // tracks the live cursor at the bottom.
191 let drop = rendered.len() - line_limit;
192 rendered.drain(0..drop);
193 } else {
194 rendered.truncate(line_limit);
195 }
196 truncated = true;
197 }
198
199 let rail_style = Style::default().fg(thinking_state_accent(state));
200 let cursor_style = Style::default().fg(palette::ACCENT_REASONING_LIVE);
201
202 if rendered.is_empty() && streaming {
203 let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)];
204 spans.push(Span::styled("reasoning...", body_style.italic()));
205 if !low_motion {
206 spans.push(Span::styled(format!(" {REASONING_CURSOR}"), cursor_style));
207 }
208 lines.push(Line::from(spans));
209 }
210
211 let last_idx = rendered.len().saturating_sub(1);
212 for (idx, line) in rendered.into_iter().enumerate() {
213 let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)];
214 spans.extend(line.spans);
215 // Trailing cursor on the very last body line while streaming —
216 // signals "still generating" without churning every line.
217 if streaming && !low_motion && idx == last_idx {
218 spans.push(Span::styled(format!(" {REASONING_CURSOR}"), cursor_style));
219 }
220 lines.push(Line::from(spans));
221 }
222
223 let needs_affordance = collapsed
224 && if streaming {
225 // #861 RC4 / #1324: during streaming, surface the affordance
226 // whenever any head lines have been clipped so the user
227 // knows there's more above and how to reach it.
228 truncated
229 } else {
230 truncated || body_text.trim() != content.trim()
231 };
232 if needs_affordance {
233 // One notation with the footer: `cap:verb`, middle-dot separator.
234 let label = if streaming {
235 "Ctrl+O:more"
236 } else {
237 "Space:expand · Ctrl+O:detail"
238 };
239 lines.push(Line::from(vec![
240 Span::styled(REASONING_RAIL.to_string(), rail_style),
241 Span::styled(label, Style::default().fg(palette::TEXT_MUTED).italic()),
242 ]));
243 }
244
245 lines
246 }
247
248 pub(super) fn render_hidden_thinking_activity(
249 _width: u16,
250 duration_secs: Option<f32>,
251 low_motion: bool,
252 ) -> Vec<Line<'static>> {
253 let state = ThinkingVisualState::Live;
254 let mut header_spans = vec![
255 Span::styled(
256 format!("{REASONING_OPENER} "),
257 Style::default().fg(thinking_state_accent(state)),
258 ),
259 // A hidden live block needs one receipt, not stacked variants of the
260 // same state ("reasoning live" plus "reasoning hidden; working").
261 Span::styled("reasoning hidden", thinking_title_style()),
262 ];
263 if let Some(dur) = duration_secs {
264 header_spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
265 header_spans.push(Span::styled(
266 crate::elapsed::format_elapsed_ms((dur * 1000.0) as u64),
267 thinking_meta_style(),
268 ));
269 }
270 if !low_motion {
271 header_spans.push(Span::styled(
272 format!(" {REASONING_CURSOR}"),
273 Style::default().fg(palette::ACCENT_REASONING_LIVE),
274 ));
275 }
276 vec![Line::from(header_spans)]
277 }
278
279 fn thinking_style() -> Style {
280 Style::default().fg(palette::TEXT_REASONING)
281 }
282
283 fn thinking_visual_state(streaming: bool, duration_secs: Option<f32>) -> ThinkingVisualState {
284 if streaming {
285 ThinkingVisualState::Live
286 } else if duration_secs.is_some() {
287 ThinkingVisualState::Done
288 } else {
289 ThinkingVisualState::Idle
290 }
291 }
292
293 fn thinking_status_label(state: ThinkingVisualState) -> &'static str {
294 match state {
295 ThinkingVisualState::Live => "live",
296 ThinkingVisualState::Done => "done",
297 ThinkingVisualState::Idle => "idle",
298 }
299 }
300
301 fn thinking_title_style() -> Style {
302 Style::default()
303 .fg(palette::TEXT_SOFT)
304 .add_modifier(Modifier::BOLD)
305 }
306
307 fn thinking_status_style(state: ThinkingVisualState) -> Style {
308 Style::default().fg(match state {
309 ThinkingVisualState::Live => palette::ACCENT_REASONING_LIVE,
310 ThinkingVisualState::Done => palette::TEXT_DIM,
311 ThinkingVisualState::Idle => palette::TEXT_DIM,
312 })
313 }
314
315 fn thinking_meta_style() -> Style {
316 Style::default().fg(palette::TEXT_DIM)
317 }
318
319 fn thinking_state_accent(state: ThinkingVisualState) -> Color {
320 match state {
321 ThinkingVisualState::Live => palette::ACCENT_REASONING_LIVE,
322 ThinkingVisualState::Done => palette::TEXT_DIM,
323 ThinkingVisualState::Idle => palette::TEXT_DIM,
324 }
325 }
326
327 /// Once-initialised colour depth for the terminal session. Avoids re-reading
328 /// `COLORTERM` / `TERM` env vars on every frame.
329 static COLOR_DEPTH: std::sync::OnceLock<palette::ColorDepth> = std::sync::OnceLock::new();
330
331 fn cached_color_depth() -> palette::ColorDepth {
332 *COLOR_DEPTH.get_or_init(palette::ColorDepth::detect)
333 }
334
334 lines RUST