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