| 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 |