返回 CodeWhale
markdown_render.rs
根目录 / crates / tui / src / tui / markdown_render.rs
1 //! Markdown rendering for TUI transcript lines.
2 //!
3 //! ## Width-independent parse vs width-dependent render (CX#6)
4 //!
5 //! The previous renderer was a single function `render_markdown(content, width)`
6 //! that scanned the source, classified each line (heading / list / code-fence /
7 //! paragraph / link), and word-wrapped to `Line<'static>` in one pass. That meant
8 //! every terminal resize forced a full re-parse of the source for every visible
9 //! cell — wasted work on the streaming cell whose content is changing anyway.
10 //!
11 //! The codex tui solves this by splitting parse from render. We mirror that:
12 //!
13 //! * [`parse`] turns the markdown source into a [`ParsedMarkdown`] AST: a vector
14 //! of width-independent [`Block`]s. The block kind already records all the
15 //! classification decisions (heading level, list bullet, code block membership)
16 //! that don't depend on width.
17 //! * [`render_parsed`] takes a `ParsedMarkdown` plus a width and a base style and
18 //! produces `Vec<Line<'static>>`. It only does word-wrap and span styling.
19 //!
20 //! [`render_markdown`] is kept as a thin convenience that does both — useful for
21 //! callers (Thinking body, message body) that don't want to manage the cache.
22 //!
23 //! The transcript cache layer (see `tui/transcript.rs`) caches the parsed AST per
24 //! cell and re-runs only the render step on width changes. That makes resize a
25 //! re-flow operation rather than a re-parse + re-flow operation.
26
27 #[cfg(test)]
28 use std::cell::Cell;
29 use std::cell::RefCell;
30 use std::sync::OnceLock;
31
32 use ratatui::style::{Color, Modifier, Style};
33 use ratatui::text::{Line, Span};
34 use syntect::easy::HighlightLines;
35 use syntect::highlighting::{FontStyle, HighlightState, Theme, ThemeSet};
36 use syntect::parsing::{ParseState as SyntectParseState, SyntaxSet};
37 use unicode_segmentation::UnicodeSegmentation;
38 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
39
40 use crate::tui::osc8;
41 use crate::tui::ui_text::CopyLineSeparator;
42 use codewhale_palette as palette;
43
44 // Thread-local counter incremented every time `parse` runs. Used by tests to
45 // prove that width-only changes hit the cached-AST path and skip parsing.
46 // Thread-local (not global atomic) so concurrent tests calling `parse()` can't
47 // pollute each other's counters.
48 #[cfg(test)]
49 thread_local! {
50 static PARSE_INVOCATIONS: Cell<u64> = const { Cell::new(0) };
51 }
52
53 #[cfg(test)]
54 #[must_use]
55 pub fn parse_invocation_count() -> u64 {
56 PARSE_INVOCATIONS.with(|c| c.get())
57 }
58
59 #[cfg(test)]
60 pub fn reset_parse_invocation_count() {
61 PARSE_INVOCATIONS.with(|c| c.set(0));
62 }
63
64 /// One classified line of markdown source, width-independent.
65 ///
66 /// All decisions that depend only on the source text (heading level, bullet
67 /// kind, whether we're inside a fenced code block, paragraph text) are made at
68 /// parse time. Width-dependent layout (word-wrap, prefix indent) is deferred to
69 /// the render step.
70 #[derive(Debug, Clone, PartialEq, Eq)]
71 pub enum Block {
72 /// `# heading text`. Includes the heading level (1..6).
73 Heading { level: usize, text: String },
74 /// A horizontal rule emitted under a level-1 heading.
75 HeadingRule,
76 /// A standalone `---` / `***` / `___` horizontal rule.
77 HorizontalRule,
78 /// A bullet (`-`/`*`) or ordered (`1.`) list item with its prefix and body.
79 ListItem { bullet: String, text: String },
80 /// A `>` quote line with its nesting depth (1 = single `>`). The text has
81 /// the quote markers stripped; depth is capped at [`MAX_QUOTE_DEPTH`].
82 Quote { depth: usize, text: String },
83 /// A line inside a fenced code block. Fences themselves are dropped, but
84 /// their language token and block identity stay available to syntect.
85 Code {
86 line: String,
87 language: Option<String>,
88 block_id: usize,
89 },
90 /// A table row: cells split on `|`.
91 TableRow(Vec<String>),
92 /// A table separator row (`|---|---|`). Kept so the renderer can draw
93 /// horizontal rules at the correct positions.
94 TableSeparator,
95 /// A non-empty paragraph line that may contain inline links.
96 Paragraph { text: String },
97 /// An empty source line, preserved so paragraph spacing survives.
98 Blank,
99 }
100
101 /// Width-independent parsed-markdown AST for one cell's source.
102 ///
103 /// Wrapped in `Arc` at the cache layer so the cache can hand the same AST to
104 /// many render calls without copying.
105 #[derive(Debug, Clone, PartialEq, Eq)]
106 pub struct ParsedMarkdown {
107 blocks: Vec<Block>,
108 }
109
110 /// Width-dependent rendered line plus the source block kind that produced it.
111 ///
112 /// Most callers only need styled terminal lines, but transcript rendering also
113 /// needs to avoid adding its conversational continuation rail in front of code
114 /// blocks. Keeping this metadata here avoids guessing from styled spans.
115 #[derive(Debug, Clone)]
116 pub struct RenderedMarkdownLine {
117 pub line: Line<'static>,
118 /// Hyperlinks aligned to display columns in `line`. Targets stay
119 /// out-of-band; `Span::content` always contains visible text only.
120 pub links: Vec<osc8::LineLink>,
121 pub is_code: bool,
122 pub copy_prefix_width: usize,
123 pub copy_separator_after: CopyLineSeparator,
124 }
125
126 static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
127 static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
128 static COLOR_DEPTH: OnceLock<palette::ColorDepth> = OnceLock::new();
129 static PALETTE_MODE: OnceLock<palette::PaletteMode> = OnceLock::new();
130
131 fn syntax_set() -> &'static SyntaxSet {
132 SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
133 }
134
135 fn theme_set() -> &'static ThemeSet {
136 THEME_SET.get_or_init(ThemeSet::load_defaults)
137 }
138
139 fn syntax_color_depth() -> palette::ColorDepth {
140 *COLOR_DEPTH.get_or_init(palette::ColorDepth::detect)
141 }
142
143 pub(crate) fn detected_palette_mode() -> palette::PaletteMode {
144 *PALETTE_MODE.get_or_init(palette::PaletteMode::detect)
145 }
146
147 /// Parse markdown source into a width-independent block AST.
148 ///
149 /// This is a small line-oriented parser tuned for the patterns we render:
150 /// fenced code blocks, ATX headings, dash/star/numbered list items, and plain
151 /// paragraphs with optional links. It does not attempt to handle every CommonMark
152 /// edge case — that's intentional. The renderer will treat anything we don't
153 /// classify as `Block::Paragraph`.
154 #[must_use]
155 pub fn parse(content: &str) -> ParsedMarkdown {
156 #[cfg(test)]
157 PARSE_INVOCATIONS.with(|c| c.set(c.get() + 1));
158
159 STREAM_PARSE_MEMO.with(|memo| {
160 let mut memo = memo.borrow_mut();
161 // Reuse the committed prefix when this call continues the same source
162 // (the streaming case). Anything else — a different cell, a shrunk
163 // buffer, an edit to earlier bytes — fails the check and starts clean.
164 let state = memo.get_or_insert_with(ParseState::default);
165 if !state.can_resume_from(content) {
166 *state = ParseState::default();
167 }
168 state.commit_complete_lines(content);
169 let parsed = state.snapshot(content);
170 // Don't hold a whole large message alive between unrelated renders.
171 if state.consumed > MAX_MEMOIZED_PREFIX_BYTES {
172 *state = ParseState::default();
173 }
174 parsed
175 })
176 }
177
178 /// Upper bound on the source we keep memoized between `parse` calls. Streaming
179 /// messages are the reason this exists; past this size the memory cost of
180 /// holding the prefix outweighs the re-parse it saves.
181 const MAX_MEMOIZED_PREFIX_BYTES: usize = 1024 * 1024;
182
183 thread_local! {
184 /// Single-entry resume memo for the streaming re-parse (#3897).
185 ///
186 /// Deliberately one entry and thread-local: the hot path is one cell
187 /// growing chunk by chunk on the render thread. A miss costs exactly what
188 /// the old code always paid, so this can only make things faster or
189 /// identical — never wrong, because [`ParseState::can_resume_from`]
190 /// verifies the prefix byte-for-byte before reusing anything.
191 static STREAM_PARSE_MEMO: RefCell<Option<ParseState>> = const { RefCell::new(None) };
192 }
193
194 /// Resumable parser state.
195 ///
196 /// The parser is strictly line-oriented: each source line maps to blocks using
197 /// only a three-field carry (`open_fence_len`, `code_language`,
198 /// `code_block_id`). That is what makes resuming *exact* rather than
199 /// approximate — appending text can never change how an earlier complete line
200 /// parsed, so committed blocks never need revisiting.
201 ///
202 /// Streaming is the case that matters (#3897): the renderer re-parses the whole
203 /// growing message on every chunk, which is quadratic over message length.
204 #[derive(Debug, Clone, Default)]
205 pub struct ParseState {
206 blocks: Vec<Block>,
207 /// The exact source bytes already folded into `blocks`. Kept verbatim so
208 /// resumption is *verified* against the new content rather than assumed —
209 /// a caller that hands over unrelated text gets a full re-parse, not
210 /// silently wrong output.
211 prefix: String,
212 /// FNV-1a digest of every byte committed since the last reset. The live
213 /// incremental cache drops `prefix` to bound memory, so the
214 /// verified-append resume path compares this digest against the incoming
215 /// content instead of retaining a second copy of the source.
216 committed_digest: u64,
217 /// Length of `prefix`. Always ends just past a newline, so only whole
218 /// lines are ever committed.
219 consumed: usize,
220 /// Length of the opening code fence in backticks while inside a fenced
221 /// code block (`None` outside). A closing fence must be at least this
222 /// long per CommonMark; shorter backtick lines are code content.
223 open_fence_len: Option<usize>,
224 code_language: Option<String>,
225 code_block_id: usize,
226 }
227
228 impl ParseState {
229 /// Fold every *complete* line after `consumed` into `blocks`.
230 ///
231 /// The trailing partial line is deliberately left uncommitted: streaming
232 /// can still extend it, and committing it early would be the one way this
233 /// could diverge from a full re-parse.
234 fn commit_complete_lines(&mut self, content: &str) {
235 let Some(rest) = content.get(self.consumed..) else {
236 return;
237 };
238 let Some(last_newline) = rest.rfind('\n') else {
239 return;
240 };
241 let complete = &rest[..=last_newline];
242 for raw_line in complete.lines() {
243 push_parsed_line(
244 raw_line,
245 &mut self.blocks,
246 &mut self.open_fence_len,
247 &mut self.code_language,
248 &mut self.code_block_id,
249 );
250 }
251 let mut digest = if self.consumed == 0 {
252 committed_prefix_digest(&[])
253 } else {
254 self.committed_digest
255 };
256 for &byte in complete.as_bytes() {
257 digest ^= u64::from(byte);
258 digest = digest.wrapping_mul(FNV_1A_PRIME);
259 }
260 self.committed_digest = digest;
261 self.prefix.push_str(complete);
262 self.consumed += complete.len();
263 }
264
265 /// The full AST: committed blocks plus the trailing partial line, parsed
266 /// against a throwaway copy of the carry so `self` stays resumable.
267 fn snapshot(&self, content: &str) -> ParsedMarkdown {
268 let tail = content.get(self.consumed..).unwrap_or_default();
269 if tail.is_empty() {
270 return ParsedMarkdown {
271 blocks: self.blocks.clone(),
272 };
273 }
274 let mut blocks = self.blocks.clone();
275 let mut open_fence_len = self.open_fence_len;
276 let mut code_language = self.code_language.clone();
277 let mut code_block_id = self.code_block_id;
278 for raw_line in tail.lines() {
279 push_parsed_line(
280 raw_line,
281 &mut blocks,
282 &mut open_fence_len,
283 &mut code_language,
284 &mut code_block_id,
285 );
286 }
287 ParsedMarkdown { blocks }
288 }
289
290 /// True when `content` still starts with everything already committed.
291 ///
292 /// Streaming only ever appends, so this is the common case. An edit that
293 /// rewrites earlier bytes (a re-render of a different cell, a retry) fails
294 /// here and the caller falls back to a full parse — correctness never
295 /// depends on the caller guessing right.
296 fn can_resume_from(&self, content: &str) -> bool {
297 content.len() >= self.consumed
298 && content.is_char_boundary(self.consumed)
299 && self.committed_prefix_matches(content)
300 }
301
302 /// Resume after the caller has proved that the raw source mutation was an
303 /// append. The live transcript obtains that proof at the `push_str` seam.
304 ///
305 /// The receipt covers the *raw* stream, but this cache consumes the
306 /// latex-rendered projection of it, and a math block that closes late
307 /// rewrites already-committed bytes: an open `\[` is committed as literal
308 /// text and only becomes its rendered form once the closing `\]` arrives
309 /// (#6196). A length check cannot see that rewrite, so the committed
310 /// prefix is verified by digest. FNV-1a over hot cache lines costs orders
311 /// of magnitude less per beat than the render it guards, while retaining
312 /// the prefix itself would pin the whole message in memory.
313 fn can_resume_verified_append(&self, content: &str) -> bool {
314 content.len() >= self.consumed
315 && content.is_char_boundary(self.consumed)
316 && self.committed_digest
317 == committed_prefix_digest(&content.as_bytes()[..self.consumed])
318 }
319
320 fn committed_prefix_matches(&self, content: &str) -> bool {
321 self.prefix == content[..self.consumed]
322 }
323 }
324
325 /// FNV-1a constants. Chosen for speed and zero dependencies: the
326 /// verified-append resume check hashes the whole committed prefix on every
327 /// streaming beat, so the guard must stay far below the render cost it
328 /// protects.
329 const FNV_1A_PRIME: u64 = 0x0000_0100_0000_01b3;
330
331 fn committed_prefix_digest(bytes: &[u8]) -> u64 {
332 const FNV_1A_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
333 let mut digest = FNV_1A_OFFSET_BASIS;
334 for &byte in bytes {
335 digest ^= u64::from(byte);
336 digest = digest.wrapping_mul(FNV_1A_PRIME);
337 }
338 digest
339 }
340
341 /// Deterministic work receipts for the live incremental renderer.
342 ///
343 /// These count source lines classified and stable/tail blocks rendered. They
344 /// deliberately do not use wall-clock time, allocator counters, or sampling,
345 /// so regression tests are stable on every machine.
346 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
347 pub(crate) struct MarkdownRenderWork {
348 pub classified_lines: u64,
349 pub stable_blocks_rendered: u64,
350 pub tail_blocks_rendered: u64,
351 pub invalidations: u64,
352 }
353
354 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
355 struct IncrementalRenderKey {
356 width: u16,
357 base_style: Style,
358 palette_mode: palette::PaletteMode,
359 }
360
361 #[derive(Debug, Clone)]
362 struct IncrementalCodeHighlighter {
363 block_id: usize,
364 language: Option<String>,
365 state: Option<(HighlightState, SyntectParseState)>,
366 }
367
368 /// Persistent state for the one changing Markdown cell in the transcript.
369 ///
370 /// Stable rendered lines live in the owning `CachedCell`; this object retains
371 /// only parser/highlighter carry plus the line index at which its replaceable
372 /// tail begins. That lets each append truncate and replace the old tail without
373 /// cloning or re-rendering the committed prefix.
374 #[derive(Debug, Default)]
375 pub(crate) struct IncrementalMarkdownRenderCache {
376 parser: ParseState,
377 key: Option<IncrementalRenderKey>,
378 source_len: usize,
379 stable_rendered_line_count: usize,
380 code_highlighter: Option<IncrementalCodeHighlighter>,
381 work: MarkdownRenderWork,
382 }
383
384 pub(crate) struct IncrementalMarkdownRenderDelta {
385 pub replace_from: usize,
386 pub lines: Vec<RenderedMarkdownLine>,
387 }
388
389 impl IncrementalMarkdownRenderCache {
390 #[cfg(test)]
391 #[must_use]
392 pub(crate) fn work(&self) -> MarkdownRenderWork {
393 self.work
394 }
395
396 #[cfg(test)]
397 #[must_use]
398 pub(crate) fn retained_source_bytes(&self) -> usize {
399 self.parser.prefix.len()
400 }
401
402 /// Update from a source mutation whose append-only provenance was recorded
403 /// by the live event loop. `verified_append` must be false for edits,
404 /// replacement text, cell reuse, or any unknown mutation.
405 pub(crate) fn update(
406 &mut self,
407 content: &str,
408 width: u16,
409 base_style: Style,
410 palette_mode: palette::PaletteMode,
411 verified_append: bool,
412 ) -> IncrementalMarkdownRenderDelta {
413 let key = IncrementalRenderKey {
414 width: width.max(1),
415 base_style,
416 palette_mode,
417 };
418
419 let can_resume = self.key == Some(key)
420 && verified_append
421 && content.len() >= self.source_len
422 && self.parser.can_resume_verified_append(content);
423 let replace_from = if can_resume {
424 self.stable_rendered_line_count
425 } else {
426 self.reset_for_invalidation();
427 self.work.invalidations = self.work.invalidations.saturating_add(1);
428 0
429 };
430 self.key = Some(key);
431
432 let before_consumed = self.parser.consumed;
433 self.parser.commit_complete_lines(content);
434 self.work.classified_lines = self.work.classified_lines.saturating_add(
435 content[before_consumed..self.parser.consumed]
436 .lines()
437 .count() as u64,
438 );
439 self.source_len = content.len();
440 // Append provenance is carried by the event-loop receipt, so the live
441 // cache does not need a second copy of already committed source. The
442 // absolute byte offset and parser carry are enough to resume.
443 self.parser.prefix.clear();
444
445 let stable_end = stable_block_prefix_len(&self.parser.blocks);
446 let mut lines = self.render_stable_prefix(stable_end, key);
447 self.stable_rendered_line_count =
448 self.stable_rendered_line_count.saturating_add(lines.len());
449
450 let mut tail_blocks = self.parser.blocks.clone();
451 tail_blocks.extend(self.parser.snapshot_tail(content));
452 if !tail_blocks.is_empty() {
453 self.work.tail_blocks_rendered = self
454 .work
455 .tail_blocks_rendered
456 .saturating_add(tail_blocks.len() as u64);
457 let mut tail_highlighter = self.code_highlighter.clone();
458 lines.extend(render_incremental_blocks(
459 &tail_blocks,
460 key,
461 &mut tail_highlighter,
462 ));
463 }
464
465 if lines.is_empty() && self.stable_rendered_line_count == 0 {
466 lines.push(empty_rendered_markdown_line());
467 }
468
469 IncrementalMarkdownRenderDelta {
470 replace_from,
471 lines,
472 }
473 }
474
475 fn render_stable_prefix(
476 &mut self,
477 end: usize,
478 key: IncrementalRenderKey,
479 ) -> Vec<RenderedMarkdownLine> {
480 if end == 0 {
481 return Vec::new();
482 }
483 self.work.stable_blocks_rendered =
484 self.work.stable_blocks_rendered.saturating_add(end as u64);
485 let lines =
486 render_incremental_blocks(&self.parser.blocks[..end], key, &mut self.code_highlighter);
487 self.parser.blocks.drain(..end);
488 lines
489 }
490
491 fn reset_for_invalidation(&mut self) {
492 self.parser = ParseState::default();
493 self.key = None;
494 self.source_len = 0;
495 self.stable_rendered_line_count = 0;
496 self.code_highlighter = None;
497 }
498 }
499
500 impl ParseState {
501 fn snapshot_tail(&self, content: &str) -> Vec<Block> {
502 let tail = content.get(self.consumed..).unwrap_or_default();
503 let mut blocks = Vec::new();
504 let mut open_fence_len = self.open_fence_len;
505 let mut code_language = self.code_language.clone();
506 let mut code_block_id = self.code_block_id;
507 for raw_line in tail.lines() {
508 push_parsed_line(
509 raw_line,
510 &mut blocks,
511 &mut open_fence_len,
512 &mut code_language,
513 &mut code_block_id,
514 );
515 }
516 blocks
517 }
518 }
519
520 fn stable_block_prefix_len(blocks: &[Block]) -> usize {
521 let Some(last_non_table) = blocks
522 .iter()
523 .rposition(|block| !matches!(block, Block::TableRow(_) | Block::TableSeparator))
524 else {
525 return 0;
526 };
527 if last_non_table + 1 == blocks.len() {
528 blocks.len()
529 } else {
530 last_non_table + 1
531 }
532 }
533
534 /// Classify one source line into blocks, advancing the fenced-code carry.
535 ///
536 /// Extracted from the original loop body unchanged so the batch and streaming
537 /// paths cannot drift: both call exactly this.
538 fn push_parsed_line(
539 raw_line: &str,
540 blocks: &mut Vec<Block>,
541 open_fence_len: &mut Option<usize>,
542 code_language: &mut Option<String>,
543 code_block_id: &mut usize,
544 ) {
545 let trimmed = raw_line.trim_start();
546 let fence_len = trimmed.chars().take_while(|c| *c == '`').count();
547 if fence_len >= 3 {
548 match *open_fence_len {
549 // Inside a code block: a fence at least as long as the opener
550 // closes it; a shorter backtick line is code content per
551 // CommonMark and must not flip the state or escape the block.
552 Some(open) if fence_len >= open && trimmed[fence_len..].trim().is_empty() => {
553 *open_fence_len = None;
554 *code_language = None;
555 }
556 Some(_) => {
557 blocks.push(Block::Code {
558 line: raw_line.to_string(),
559 language: code_language.clone(),
560 block_id: *code_block_id,
561 });
562 }
563 None => {
564 *open_fence_len = Some(fence_len);
565 *code_block_id = code_block_id.saturating_add(1);
566 *code_language = normalized_fence_language(&trimmed[fence_len..]);
567 }
568 }
569 return;
570 }
571
572 if open_fence_len.is_some() {
573 blocks.push(Block::Code {
574 line: raw_line.to_string(),
575 language: code_language.clone(),
576 block_id: *code_block_id,
577 });
578 return;
579 }
580
581 if let Some((depth, text)) = parse_blockquote(trimmed) {
582 blocks.push(Block::Quote {
583 depth,
584 text: text.to_string(),
585 });
586 return;
587 }
588
589 if let Some((level, text)) = parse_heading(trimmed) {
590 blocks.push(Block::Heading {
591 level,
592 text: text.to_string(),
593 });
594 if level == 1 {
595 blocks.push(Block::HeadingRule);
596 }
597 return;
598 }
599
600 if let Some((bullet, text)) = parse_list_item(trimmed) {
601 blocks.push(Block::ListItem {
602 bullet,
603 text: text.to_string(),
604 });
605 return;
606 }
607
608 if is_horizontal_rule(trimmed) {
609 blocks.push(Block::HorizontalRule);
610 return;
611 }
612
613 match parse_table_row(trimmed) {
614 Some(cells) => {
615 blocks.push(Block::TableRow(cells));
616 return;
617 }
618 None if trimmed.starts_with('|') => {
619 blocks.push(Block::TableSeparator);
620 return;
621 }
622 None => {}
623 }
624
625 if trimmed.is_empty() {
626 // Whitespace-only lines are blank paragraphs.
627 blocks.push(Block::Blank);
628 return;
629 }
630
631 blocks.push(Block::Paragraph {
632 text: raw_line.to_string(),
633 });
634 }
635
636 /// Render a parsed-markdown AST at the given terminal width.
637 ///
638 /// This is the width-dependent half: word-wrapping, link styling, code-block
639 /// formatting. The AST is owned by the caller (typically the transcript cache),
640 /// so width-only changes can call `render_parsed` again with the same AST and
641 /// skip the parse step entirely.
642 #[must_use]
643 pub fn render_parsed(parsed: &ParsedMarkdown, width: u16, base_style: Style) -> Vec<Line<'static>> {
644 render_parsed_tagged_with_palette(parsed, width, base_style, detected_palette_mode())
645 .into_iter()
646 .map(|line| line.line)
647 .collect()
648 }
649
650 /// Render a parsed-markdown AST and preserve per-line source metadata.
651 #[cfg(test)]
652 #[must_use]
653 pub fn render_parsed_tagged(
654 parsed: &ParsedMarkdown,
655 width: u16,
656 base_style: Style,
657 ) -> Vec<RenderedMarkdownLine> {
658 render_parsed_tagged_with_palette(parsed, width, base_style, detected_palette_mode())
659 }
660
661 /// Render parsed markdown using the caller's resolved UI palette mode.
662 ///
663 /// The live transcript uses this entry point so an explicit theme selection
664 /// wins over terminal/OS auto-detection and participates in cache invalidation.
665 #[must_use]
666 pub(crate) fn render_parsed_tagged_with_palette(
667 parsed: &ParsedMarkdown,
668 width: u16,
669 base_style: Style,
670 palette_mode: palette::PaletteMode,
671 ) -> Vec<RenderedMarkdownLine> {
672 let width = width.max(1) as usize;
673 let mut out: Vec<RenderedMarkdownLine> = Vec::with_capacity(parsed.blocks.len());
674
675 let mut i = 0;
676 while i < parsed.blocks.len() {
677 if matches!(
678 &parsed.blocks[i],
679 Block::TableRow(_) | Block::TableSeparator
680 ) {
681 let start = i;
682 while i < parsed.blocks.len()
683 && matches!(
684 &parsed.blocks[i],
685 Block::TableRow(_) | Block::TableSeparator
686 )
687 {
688 i += 1;
689 }
690 out.extend(
691 render_table_group(&parsed.blocks[start..i], width, base_style)
692 .into_iter()
693 .map(|line| RenderedMarkdownLine {
694 line,
695 links: Vec::new(),
696 is_code: false,
697 copy_prefix_width: 0,
698 copy_separator_after: CopyLineSeparator::Newline,
699 }),
700 );
701 continue;
702 }
703
704 if let Block::Code {
705 language, block_id, ..
706 } = &parsed.blocks[i]
707 {
708 let start = i;
709 while i < parsed.blocks.len()
710 && matches!(
711 &parsed.blocks[i],
712 Block::Code {
713 block_id: candidate,
714 ..
715 } if candidate == block_id
716 )
717 {
718 i += 1;
719 }
720 let source_lines = parsed.blocks[start..i]
721 .iter()
722 .filter_map(|block| match block {
723 Block::Code { line, .. } => Some(line.as_str()),
724 _ => None,
725 })
726 .collect::<Vec<_>>();
727 let highlighted =
728 highlight_code_block(language.as_deref(), &source_lines, base_style, palette_mode);
729 for spans in highlighted {
730 out.extend(render_wrapped_code_spans_tagged(spans, width));
731 }
732 continue;
733 }
734
735 match &parsed.blocks[i] {
736 Block::Heading { text, .. } => {
737 let style = Style::default()
738 .fg(palette::WHALE_ACTION)
739 .add_modifier(Modifier::BOLD);
740 out.extend(render_wrapped_line_tagged(text, width, style, false, false));
741 }
742 Block::HeadingRule => {
743 out.push(RenderedMarkdownLine {
744 line: Line::from(Span::styled(
745 "─".repeat(width.min(40)),
746 Style::default().fg(palette::TEXT_DIM),
747 )),
748 links: Vec::new(),
749 is_code: false,
750 copy_prefix_width: 0,
751 copy_separator_after: CopyLineSeparator::Newline,
752 });
753 }
754 Block::HorizontalRule => {
755 out.push(RenderedMarkdownLine {
756 line: Line::from(Span::styled(
757 "─".repeat(width.min(60)),
758 Style::default().fg(palette::TEXT_DIM),
759 )),
760 links: Vec::new(),
761 is_code: false,
762 copy_prefix_width: 0,
763 copy_separator_after: CopyLineSeparator::Newline,
764 });
765 }
766 Block::ListItem { bullet, text } => {
767 let bullet_style = Style::default().fg(palette::WHALE_ACTION);
768 out.extend(render_list_line_tagged(
769 bullet,
770 text,
771 width,
772 bullet_style,
773 base_style,
774 ));
775 }
776 Block::Code { .. } => unreachable!(),
777 Block::Quote { depth, text } => {
778 let rail_style = Style::default().fg(palette::WHALE_ACTION);
779 let text_style = Style::default().fg(palette::TEXT_DIM);
780 out.extend(render_quote_line_tagged(
781 text, *depth, width, rail_style, text_style,
782 ));
783 }
784 Block::Paragraph { text } => {
785 let link_style = Style::default()
786 .fg(palette::WHALE_ACTION)
787 .add_modifier(Modifier::UNDERLINED);
788 out.extend(render_line_with_links_tagged(
789 text, width, base_style, link_style,
790 ));
791 }
792 Block::Blank => {
793 out.push(RenderedMarkdownLine {
794 line: Line::from(""),
795 links: Vec::new(),
796 is_code: false,
797 copy_prefix_width: 0,
798 copy_separator_after: CopyLineSeparator::Newline,
799 });
800 }
801 Block::TableRow(_) | Block::TableSeparator => unreachable!(),
802 }
803 i += 1;
804 }
805
806 if out.is_empty() {
807 out.push(RenderedMarkdownLine {
808 line: Line::from(""),
809 links: Vec::new(),
810 is_code: false,
811 copy_prefix_width: 0,
812 copy_separator_after: CopyLineSeparator::Newline,
813 });
814 }
815
816 out
817 }
818
819 fn empty_rendered_markdown_line() -> RenderedMarkdownLine {
820 RenderedMarkdownLine {
821 line: Line::from(""),
822 links: Vec::new(),
823 is_code: false,
824 copy_prefix_width: 0,
825 copy_separator_after: CopyLineSeparator::Newline,
826 }
827 }
828
829 /// Render a block suffix while carrying syntax state across calls.
830 ///
831 /// Non-code blocks use the canonical batch renderer unchanged. Code lines are
832 /// the only group whose styling depends on preceding blocks, so their syntect
833 /// parse/highlight state is retained explicitly and cloned for the replaceable
834 /// tail. This keeps an open fence incremental without sacrificing exact final
835 /// highlighting.
836 fn render_incremental_blocks(
837 blocks: &[Block],
838 key: IncrementalRenderKey,
839 code_highlighter: &mut Option<IncrementalCodeHighlighter>,
840 ) -> Vec<RenderedMarkdownLine> {
841 let mut out = Vec::new();
842 let mut index = 0;
843 while index < blocks.len() {
844 if let Block::Code {
845 line,
846 language,
847 block_id,
848 } = &blocks[index]
849 {
850 let spans = highlight_incremental_code_line(
851 *block_id,
852 language.as_deref(),
853 line,
854 key.base_style,
855 key.palette_mode,
856 code_highlighter,
857 );
858 out.extend(render_wrapped_code_spans_tagged(
859 spans,
860 usize::from(key.width),
861 ));
862 index += 1;
863 continue;
864 }
865
866 let start = index;
867 while index < blocks.len() && !matches!(blocks[index], Block::Code { .. }) {
868 index += 1;
869 }
870 out.extend(render_parsed_tagged_with_palette(
871 &ParsedMarkdown {
872 blocks: blocks[start..index].to_vec(),
873 },
874 key.width,
875 key.base_style,
876 key.palette_mode,
877 ));
878 }
879 out
880 }
881
882 fn highlight_incremental_code_line(
883 block_id: usize,
884 language: Option<&str>,
885 line: &str,
886 base_style: Style,
887 palette_mode: palette::PaletteMode,
888 cache: &mut Option<IncrementalCodeHighlighter>,
889 ) -> Vec<Span<'static>> {
890 let language_owned = language.map(str::to_owned);
891 let needs_reset = cache
892 .as_ref()
893 .is_none_or(|current| current.block_id != block_id || current.language != language_owned);
894 if needs_reset {
895 let state = language
896 .and_then(find_code_syntax)
897 .map(|syntax| HighlightLines::new(syntax, selected_syntax_theme(palette_mode)).state());
898 *cache = Some(IncrementalCodeHighlighter {
899 block_id,
900 language: language_owned,
901 state,
902 });
903 }
904
905 let plain_style = base_style.fg(palette::TEXT_TOOL_OUTPUT);
906 let Some(current) = cache.as_mut() else {
907 return vec![Span::styled(line.to_string(), plain_style)];
908 };
909 let Some((highlight_state, parse_state)) = current.state.take() else {
910 return vec![Span::styled(line.to_string(), plain_style)];
911 };
912 let mut highlighter = HighlightLines::from_state(
913 selected_syntax_theme(palette_mode),
914 highlight_state,
915 parse_state,
916 );
917 let highlighted = match highlighter.highlight_line(line, syntax_set()) {
918 Ok(ranges) if !ranges.is_empty() => ranges
919 .into_iter()
920 .map(|(style, text)| {
921 Span::styled(
922 text.to_string(),
923 syntax_style_to_ratatui(style, base_style, palette_mode),
924 )
925 })
926 .collect(),
927 _ => vec![Span::styled(line.to_string(), plain_style)],
928 };
929 current.state = Some(highlighter.state());
930 highlighted
931 }
932
933 /// Convenience wrapper: parse + render in one call.
934 ///
935 /// Equivalent to `render_parsed(&parse(content), width, base_style)`. Callers
936 /// that don't manage their own cache (the Thinking body, the immediate message
937 /// body) use this.
938 #[must_use]
939 pub fn render_markdown(content: &str, width: u16, base_style: Style) -> Vec<Line<'static>> {
940 let parsed = parse(content);
941 render_parsed(&parsed, width, base_style)
942 }
943
944 /// Convenience wrapper: parse + render while keeping per-line source metadata.
945 #[cfg(test)]
946 #[must_use]
947 pub fn render_markdown_tagged(
948 content: &str,
949 width: u16,
950 base_style: Style,
951 ) -> Vec<RenderedMarkdownLine> {
952 let parsed = parse(content);
953 render_parsed_tagged(&parsed, width, base_style)
954 }
955
956 /// Parse and render markdown using an already-resolved UI palette mode.
957 #[must_use]
958 pub(crate) fn render_markdown_tagged_with_palette(
959 content: &str,
960 width: u16,
961 base_style: Style,
962 palette_mode: palette::PaletteMode,
963 ) -> Vec<RenderedMarkdownLine> {
964 let parsed = parse(content);
965 render_parsed_tagged_with_palette(&parsed, width, base_style, palette_mode)
966 }
967
968 /// Render plain text: split on newlines, word-wrap each line independently,
969 /// preserve leading whitespace and blank lines. No markdown interpretation.
970 #[must_use]
971 pub fn render_plain_text(content: &str, width: u16, base_style: Style) -> Vec<Line<'static>> {
972 let width = width.max(1) as usize;
973 let mut lines = Vec::new();
974 for raw_line in content.split('\n') {
975 if raw_line.is_empty() {
976 lines.push(Line::from(""));
977 } else {
978 lines.extend(wrap_plain_line(raw_line, width, base_style));
979 }
980 }
981 if lines.is_empty() {
982 lines.push(Line::from(""));
983 }
984 lines
985 }
986
987 /// Word-wrap a single line at `width`, preserving leading whitespace.
988 /// Handles over-long words by char-breaking (same strategy as the markdown
989 /// line renderer).
990 fn wrap_plain_line(line: &str, width: usize, style: Style) -> Vec<Line<'static>> {
991 if width == 0 || line.is_empty() {
992 return vec![Line::from("")];
993 }
994
995 let mut chunks = Vec::new();
996 let mut current = String::new();
997 let mut current_width = 0usize;
998 let mut last_break_pos = None;
999
1000 for grapheme in line.graphemes(true) {
1001 loop {
1002 let grapheme_width = markdown_grapheme_width(grapheme, current_width);
1003 if current_width + grapheme_width <= width || current.is_empty() {
1004 break;
1005 }
1006
1007 if let Some(pos) = last_break_pos {
1008 if pos == current.len() {
1009 chunks.push(std::mem::take(&mut current));
1010 current_width = 0;
1011 last_break_pos = None;
1012 break;
1013 }
1014
1015 if current[..pos].chars().any(|c| !c.is_whitespace()) {
1016 let tail = current.split_off(pos);
1017 chunks.push(std::mem::take(&mut current));
1018 current = tail;
1019 current_width = plain_display_width(&current);
1020 last_break_pos = last_plain_break_pos(&current);
1021 continue;
1022 }
1023 }
1024
1025 chunks.push(std::mem::take(&mut current));
1026 current_width = 0;
1027 last_break_pos = None;
1028 break;
1029 }
1030
1031 let grapheme_width = markdown_grapheme_width(grapheme, current_width);
1032 current.push_str(grapheme);
1033 current_width += grapheme_width;
1034 if grapheme.chars().all(char::is_whitespace) {
1035 last_break_pos = Some(current.len());
1036 }
1037 }
1038
1039 if !current.is_empty() {
1040 chunks.push(current);
1041 }
1042
1043 if chunks.is_empty() {
1044 return vec![Line::from("")];
1045 }
1046
1047 chunks
1048 .into_iter()
1049 .map(|chunk| Line::from(vec![Span::styled(chunk, style)]))
1050 .collect()
1051 }
1052
1053 fn plain_display_width(text: &str) -> usize {
1054 let mut width = 0usize;
1055 for grapheme in text.graphemes(true) {
1056 width += markdown_grapheme_width(grapheme, width);
1057 }
1058 width
1059 }
1060
1061 fn last_plain_break_pos(text: &str) -> Option<usize> {
1062 text.char_indices()
1063 .rev()
1064 .find_map(|(idx, ch)| ch.is_whitespace().then_some(idx + ch.len_utf8()))
1065 }
1066
1067 fn parse_heading(line: &str) -> Option<(usize, &str)> {
1068 let trimmed = line.trim_start();
1069 let hashes = trimmed.chars().take_while(|c| *c == '#').count();
1070 if hashes == 0 {
1071 return None;
1072 }
1073 let text = trimmed[hashes..].trim();
1074 if text.is_empty() {
1075 None
1076 } else {
1077 Some((hashes, text))
1078 }
1079 }
1080
1081 fn parse_list_item(line: &str) -> Option<(String, &str)> {
1082 let trimmed = line.trim_start();
1083 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
1084 return Some(("-".to_string(), trimmed[2..].trim()));
1085 }
1086 let bytes = trimmed.as_bytes();
1087 let mut idx = 0;
1088 while idx < bytes.len() && bytes[idx].is_ascii_digit() {
1089 idx += 1;
1090 }
1091 if idx == 0 || idx >= bytes.len() || bytes[idx] != b'.' {
1092 return None;
1093 }
1094 let rest = &trimmed[idx + 1..];
1095 if !rest.starts_with(' ') {
1096 return None;
1097 }
1098 Some((format!("{}.", &trimmed[..idx]), rest.trim_start()))
1099 }
1100
1101 /// Upper bound on the nesting depth rendered for a `>` quote. Deeper quotes
1102 /// are clamped and the extra markers dropped from the rendered text; capping
1103 /// stops a pathological input like `>>>>>>>>>>>> text` from consuming the
1104 /// whole line width in rails.
1105 const MAX_QUOTE_DEPTH: usize = 4;
1106
1107 /// Parse a `>` blockquote line, returning `(depth, text)`.
1108 ///
1109 /// CommonMark nests with `>>` or `> >`; we count every leading `>` regardless
1110 /// of interleaved spaces, then trim the remaining content. A lone `>` yields
1111 /// an empty quote line. Deliberately lenient about missing space after `>` so
1112 /// model output like `>note` still renders as a quote.
1113 fn parse_blockquote(line: &str) -> Option<(usize, &str)> {
1114 let trimmed = line.trim_start();
1115 if !trimmed.starts_with('>') {
1116 return None;
1117 }
1118 let mut rest = trimmed;
1119 let mut depth = 0usize;
1120 while rest.starts_with('>') {
1121 depth = depth.saturating_add(1);
1122 rest = rest[1..].trim_start_matches([' ', '\t']);
1123 }
1124 Some((depth.clamp(1, MAX_QUOTE_DEPTH), rest.trim()))
1125 }
1126
1127 fn normalized_fence_language(info: &str) -> Option<String> {
1128 let token = info
1129 .trim()
1130 .split(|ch: char| ch.is_whitespace() || ch == ',')
1131 .next()
1132 .unwrap_or("")
1133 .trim_matches(['{', '}', '.'])
1134 .to_ascii_lowercase();
1135 if token.is_empty() || matches!(token.as_str(), "text" | "txt" | "plain" | "plaintext") {
1136 return None;
1137 }
1138 let normalized = match token.as_str() {
1139 "rs" => "rust",
1140 "js" | "jsx" | "node" => "javascript",
1141 "ts" | "tsx" => "typescript",
1142 "py" => "python",
1143 "rb" => "ruby",
1144 "sh" | "shell" | "zsh" => "bash",
1145 "yml" => "yaml",
1146 "md" => "markdown",
1147 other => other,
1148 };
1149 Some(normalized.to_string())
1150 }
1151
1152 fn selected_syntax_theme(mode: palette::PaletteMode) -> &'static Theme {
1153 let themes = theme_set();
1154 let preferred = match mode {
1155 palette::PaletteMode::Dark | palette::PaletteMode::Grayscale => "base16-ocean.dark",
1156 palette::PaletteMode::Light => "InspiredGitHub",
1157 palette::PaletteMode::SolarizedLight => "Solarized (light)",
1158 };
1159 themes
1160 .themes
1161 .get(preferred)
1162 .or_else(|| themes.themes.values().next())
1163 .expect("syntect ships at least one default theme")
1164 }
1165
1166 fn syntax_style_to_ratatui(
1167 style: syntect::highlighting::Style,
1168 base_style: Style,
1169 palette_mode: palette::PaletteMode,
1170 ) -> Style {
1171 let fg = syntax_rgb_to_terminal_color(
1172 style.foreground.r,
1173 style.foreground.g,
1174 style.foreground.b,
1175 palette_mode,
1176 syntax_color_depth(),
1177 );
1178 let mut modifiers = Modifier::empty();
1179 if style.font_style.contains(FontStyle::BOLD) {
1180 modifiers |= Modifier::BOLD;
1181 }
1182 if style.font_style.contains(FontStyle::ITALIC) {
1183 modifiers |= Modifier::ITALIC;
1184 }
1185 if style.font_style.contains(FontStyle::UNDERLINE) {
1186 modifiers |= Modifier::UNDERLINED;
1187 }
1188 base_style.fg(fg).add_modifier(modifiers)
1189 }
1190
1191 fn syntax_rgb_to_terminal_color(
1192 r: u8,
1193 g: u8,
1194 b: u8,
1195 mode: palette::PaletteMode,
1196 depth: palette::ColorDepth,
1197 ) -> Color {
1198 let (r, g, b) = if mode == palette::PaletteMode::Grayscale {
1199 let luma =
1200 ((u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114 + 500) / 1000) as u8;
1201 let readable = luma.clamp(96, 232);
1202 (readable, readable, readable)
1203 } else {
1204 (r, g, b)
1205 };
1206 let mut color = Color::Rgb(r, g, b);
1207 if matches!(
1208 color,
1209 reserved if reserved == palette::WHALE_HUMAN
1210 || reserved == palette::WHALE_LIVE
1211 || reserved == palette::WHALE_ACTION
1212 || reserved == palette::WHALE_ERROR
1213 ) {
1214 // Syntax colors are content, not brand/attention/work/danger state.
1215 // Shift exact collisions before terminal-depth reduction so the cell
1216 // cannot acquire a reserved semantic role in the color backend.
1217 color = Color::Rgb(r, g, b.saturating_add(1));
1218 }
1219 let color = palette::adapt_color(color, depth);
1220 let reserved = [
1221 palette::WHALE_HUMAN,
1222 palette::WHALE_LIVE,
1223 palette::WHALE_ACTION,
1224 palette::WHALE_ERROR,
1225 ]
1226 .map(|semantic| palette::adapt_color(semantic, depth));
1227 if !reserved.contains(&color) {
1228 return color;
1229 }
1230
1231 // Quantization can make distinct RGB values collide again. Walk a small,
1232 // deterministic neutral ramp until the terminal-level color no longer
1233 // impersonates one of the four reserved semantic lanes.
1234 for delta in [17_u8, 34, 51, 68, 85, 102, 119, 136] {
1235 let candidate = palette::adapt_color(
1236 Color::Rgb(
1237 r.wrapping_add(delta),
1238 g.wrapping_add(delta / 2),
1239 b.wrapping_add(delta / 3),
1240 ),
1241 depth,
1242 );
1243 if !reserved.contains(&candidate) {
1244 return candidate;
1245 }
1246 }
1247 // All supported depths have more than four colors, so this is only a
1248 // defensive fallback for a future adapter with a narrower gamut.
1249 Color::Reset
1250 }
1251
1252 fn highlight_code_block(
1253 language: Option<&str>,
1254 lines: &[&str],
1255 base_style: Style,
1256 palette_mode: palette::PaletteMode,
1257 ) -> Vec<Vec<Span<'static>>> {
1258 let plain_style = base_style.fg(palette::TEXT_TOOL_OUTPUT);
1259 let Some(language) = language else {
1260 return lines
1261 .iter()
1262 .map(|line| vec![Span::styled((*line).to_string(), plain_style)])
1263 .collect();
1264 };
1265 let syntaxes = syntax_set();
1266 let Some(syntax) = find_code_syntax(language) else {
1267 return lines
1268 .iter()
1269 .map(|line| vec![Span::styled((*line).to_string(), plain_style)])
1270 .collect();
1271 };
1272
1273 let mut highlighter = HighlightLines::new(syntax, selected_syntax_theme(palette_mode));
1274 lines
1275 .iter()
1276 .map(|line| match highlighter.highlight_line(line, syntaxes) {
1277 Ok(ranges) if !ranges.is_empty() => ranges
1278 .into_iter()
1279 .map(|(style, text)| {
1280 Span::styled(
1281 text.to_string(),
1282 syntax_style_to_ratatui(style, base_style, palette_mode),
1283 )
1284 })
1285 .collect(),
1286 _ => vec![Span::styled((*line).to_string(), plain_style)],
1287 })
1288 .collect()
1289 }
1290
1291 fn find_code_syntax(language: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
1292 let syntaxes = syntax_set();
1293 syntaxes
1294 .find_syntax_by_token(language)
1295 .or_else(|| syntaxes.find_syntax_by_extension(language))
1296 .or_else(|| {
1297 syntaxes
1298 .syntaxes()
1299 .iter()
1300 .find(|syntax| syntax.name.eq_ignore_ascii_case(language))
1301 })
1302 }
1303
1304 fn render_wrapped_code_spans_tagged(
1305 spans: Vec<Span<'static>>,
1306 width: usize,
1307 ) -> Vec<RenderedMarkdownLine> {
1308 let prefix = " ";
1309 let prefix_width = prefix.width();
1310 let available = width.saturating_sub(prefix_width).max(1);
1311 let mut rows: Vec<Vec<(String, Style)>> = vec![Vec::new()];
1312 let mut current_width = 0usize;
1313
1314 for span in spans {
1315 for grapheme in span.content.graphemes(true) {
1316 let grapheme_width = markdown_grapheme_width(grapheme, current_width);
1317 if current_width + grapheme_width > available && current_width > 0 {
1318 rows.push(Vec::new());
1319 current_width = 0;
1320 }
1321 let row = rows.last_mut().expect("code rows are never empty");
1322 if let Some((text, style)) = row.last_mut()
1323 && *style == span.style
1324 {
1325 text.push_str(grapheme);
1326 } else {
1327 row.push((grapheme.to_string(), span.style));
1328 }
1329 current_width += markdown_grapheme_width(grapheme, current_width);
1330 }
1331 }
1332
1333 let last_index = rows.len().saturating_sub(1);
1334 rows.into_iter()
1335 .enumerate()
1336 .map(|(idx, row)| {
1337 let mut rendered = vec![Span::raw(prefix)];
1338 rendered.extend(
1339 row.into_iter()
1340 .map(|(text, style)| Span::styled(text, style)),
1341 );
1342 RenderedMarkdownLine {
1343 line: Line::from(rendered),
1344 links: Vec::new(),
1345 is_code: true,
1346 copy_prefix_width: prefix_width,
1347 copy_separator_after: if idx == last_index {
1348 CopyLineSeparator::Newline
1349 } else {
1350 CopyLineSeparator::None
1351 },
1352 }
1353 })
1354 .collect()
1355 }
1356
1357 fn render_wrapped_line_tagged(
1358 line: &str,
1359 width: usize,
1360 style: Style,
1361 indent_code: bool,
1362 is_code: bool,
1363 ) -> Vec<RenderedMarkdownLine> {
1364 let prefix = if indent_code { " " } else { "" };
1365 let prefix_width = prefix.width();
1366 let available = width.saturating_sub(prefix_width).max(1);
1367 // Code blocks must preserve leading whitespace (indentation is semantic).
1368 // Use hard character-width wrapping instead of word-wrap.
1369 let wrapped = if indent_code {
1370 wrap_code_line(line, available)
1371 } else {
1372 wrap_text(line, available)
1373 };
1374 let mut out = Vec::new();
1375
1376 let last_index = wrapped.len().saturating_sub(1);
1377 for (idx, chunk) in wrapped.into_iter().enumerate() {
1378 let line = if idx == 0 {
1379 Line::from(vec![Span::raw(prefix), Span::styled(chunk, style)])
1380 } else {
1381 Line::from(vec![
1382 Span::raw(" ".repeat(prefix_width)),
1383 Span::styled(chunk, style),
1384 ])
1385 };
1386 let copy_separator_after = if idx == last_index {
1387 CopyLineSeparator::Newline
1388 } else if is_code {
1389 CopyLineSeparator::None
1390 } else {
1391 CopyLineSeparator::Space
1392 };
1393 out.push(RenderedMarkdownLine {
1394 line,
1395 links: Vec::new(),
1396 is_code,
1397 copy_prefix_width: if indent_code { prefix_width } else { 0 },
1398 copy_separator_after,
1399 });
1400 }
1401
1402 out
1403 }
1404
1405 fn render_list_line_tagged(
1406 bullet: &str,
1407 text: &str,
1408 width: usize,
1409 bullet_style: Style,
1410 text_style: Style,
1411 ) -> Vec<RenderedMarkdownLine> {
1412 let bullet_prefix = format!("{bullet} ");
1413 let bullet_width = bullet_prefix.width();
1414 let available = width.saturating_sub(bullet_width).max(1);
1415 let wrapped = render_line_with_links_tagged(text, available, text_style, link_style());
1416
1417 let mut out = Vec::new();
1418 for (idx, rendered) in wrapped.into_iter().enumerate() {
1419 let links = rendered
1420 .links
1421 .iter()
1422 .map(|link| link.shifted(bullet_width))
1423 .collect();
1424 if idx == 0 {
1425 let mut spans = vec![Span::styled(bullet_prefix.clone(), bullet_style)];
1426 spans.extend(rendered.line.spans);
1427 out.push(RenderedMarkdownLine {
1428 line: Line::from(spans),
1429 links,
1430 is_code: false,
1431 copy_prefix_width: 0,
1432 copy_separator_after: rendered.copy_separator_after,
1433 });
1434 } else {
1435 let mut spans = vec![Span::raw(" ".repeat(bullet_width))];
1436 spans.extend(rendered.line.spans);
1437 out.push(RenderedMarkdownLine {
1438 line: Line::from(spans),
1439 links,
1440 is_code: false,
1441 copy_prefix_width: bullet_width,
1442 copy_separator_after: rendered.copy_separator_after,
1443 });
1444 }
1445 }
1446 out
1447 }
1448
1449 /// Render a `>` quote line: a vertical-rule rail per nesting depth plus the
1450 /// quote text with inline formatting (bold, code, links).
1451 ///
1452 /// The rail is display chrome, not source markup. On the first row the
1453 /// transcript rail-scan (`compute_rail_prefix_width`) already strips it along
1454 /// with the assistant glyph, so `copy_prefix_width` must be `0` there — like
1455 /// list items — or selection copy would strip the rail twice and lose quote
1456 /// text. Wrapped continuation rows have no rail scan (plain spaces), so they
1457 /// report the rail width so selection copy skips their alignment.
1458 fn render_quote_line_tagged(
1459 text: &str,
1460 depth: usize,
1461 width: usize,
1462 rail_style: Style,
1463 text_style: Style,
1464 ) -> Vec<RenderedMarkdownLine> {
1465 let depth = depth.clamp(1, MAX_QUOTE_DEPTH);
1466 let rail = "│ ".repeat(depth);
1467 let rail_width = rail.width();
1468 let available = width.saturating_sub(rail_width).max(1);
1469 let wrapped = render_line_with_links_tagged(text, available, text_style, link_style());
1470
1471 let mut out = Vec::new();
1472 for (idx, rendered) in wrapped.into_iter().enumerate() {
1473 let links = rendered
1474 .links
1475 .iter()
1476 .map(|link| link.shifted(rail_width))
1477 .collect();
1478 let mut spans = if idx == 0 {
1479 (0..depth).map(|_| Span::styled("│ ", rail_style)).collect()
1480 } else {
1481 vec![Span::raw(" ".repeat(rail_width))]
1482 };
1483 spans.extend(rendered.line.spans);
1484 out.push(RenderedMarkdownLine {
1485 line: Line::from(spans),
1486 links,
1487 is_code: false,
1488 // First row: the transcript rail-scan strips the visible rail
1489 // (mirror `render_list_line_tagged`); continuation rows: report
1490 // the alignment width so selection copy skips it.
1491 copy_prefix_width: if idx == 0 { 0 } else { rail_width },
1492 copy_separator_after: rendered.copy_separator_after,
1493 });
1494 }
1495 out
1496 }
1497
1498 #[cfg(test)]
1499 fn render_line_with_links(
1500 line: &str,
1501 width: usize,
1502 base_style: Style,
1503 link_style: Style,
1504 ) -> Vec<Line<'static>> {
1505 render_line_with_links_tagged(line, width, base_style, link_style)
1506 .into_iter()
1507 .map(|rendered| rendered.line)
1508 .collect()
1509 }
1510
1511 fn render_line_with_links_tagged(
1512 line: &str,
1513 width: usize,
1514 base_style: Style,
1515 link_style: Style,
1516 ) -> Vec<RenderedMarkdownLine> {
1517 if line.trim().is_empty() {
1518 return vec![RenderedMarkdownLine {
1519 line: Line::from(""),
1520 links: Vec::new(),
1521 is_code: false,
1522 copy_prefix_width: 0,
1523 copy_separator_after: CopyLineSeparator::Newline,
1524 }];
1525 }
1526
1527 // Flatten inline tokens into (word, style) pairs preserving inter-token spaces.
1528 let tokens = parse_inline_spans(line, base_style, link_style);
1529 let mut words: Vec<InlineToken> = Vec::new();
1530 for token in tokens {
1531 let mut first = true;
1532 for part in token.text.split(' ') {
1533 if !first {
1534 // The space consumed by split remains part of a markdown-link
1535 // label when the surrounding token is linked. It is still a
1536 // wrap opportunity and is dropped at a row boundary.
1537 words.push(InlineToken::new(
1538 " ".to_string(),
1539 token.style,
1540 token.link_url.clone(),
1541 ));
1542 }
1543 if !part.is_empty() {
1544 words.push(InlineToken::new(
1545 part.to_string(),
1546 token.style,
1547 token.link_url.clone(),
1548 ));
1549 }
1550 first = false;
1551 }
1552 }
1553
1554 let mut lines: Vec<RenderedMarkdownLine> = Vec::new();
1555 let mut current_spans: Vec<Span<'static>> = Vec::new();
1556 let mut current_links: Vec<osc8::LineLink> = Vec::new();
1557 let mut current_width = 0usize;
1558
1559 for word in words {
1560 let ww = word.text.width();
1561 if word.text == " " {
1562 // Space: emit only if we're mid-line and it fits; otherwise drop
1563 // (it's a potential wrap point, not content).
1564 if !current_spans.is_empty() && current_width < width {
1565 current_spans.push(word.span_for(" ".to_string()));
1566 record_inline_link(&mut current_links, &word, current_width, 1);
1567 current_width += 1;
1568 }
1569 continue;
1570 }
1571 // If the word itself is wider than an entire line, hard-break it at
1572 // grapheme boundaries so wrapping always makes progress (#1344,
1573 // #1351). Without this, long URLs / paths / hashes were placed on
1574 // their own line whole and silently overflowed the right edge of
1575 // the transcript.
1576 if ww > width && width > 0 {
1577 // Flush the in-progress line first.
1578 if !current_spans.is_empty() {
1579 push_inline_line(
1580 &mut lines,
1581 &mut current_spans,
1582 &mut current_links,
1583 CopyLineSeparator::Space,
1584 );
1585 current_width = 0;
1586 }
1587 // Char-break the word into width-sized chunks. Each full chunk
1588 // becomes its own line; the final partial chunk continues the
1589 // current line so the next word can pack onto it.
1590 let mut chunk = String::new();
1591 let mut chunk_w = 0usize;
1592 for grapheme in word.text.graphemes(true) {
1593 let grapheme_width = grapheme.width();
1594 if chunk_w + grapheme_width > width && chunk_w > 0 {
1595 let chunk = std::mem::take(&mut chunk);
1596 let mut links = Vec::new();
1597 record_inline_link(&mut links, &word, 0, chunk_w);
1598 lines.push(RenderedMarkdownLine {
1599 line: Line::from(vec![word.span_for(chunk)]),
1600 links,
1601 is_code: false,
1602 copy_prefix_width: 0,
1603 copy_separator_after: CopyLineSeparator::None,
1604 });
1605 chunk_w = 0;
1606 }
1607 chunk.push_str(grapheme);
1608 chunk_w += grapheme_width;
1609 }
1610 if !chunk.is_empty() {
1611 record_inline_link(&mut current_links, &word, 0, chunk_w);
1612 current_spans.push(word.span_for(chunk));
1613 current_width = chunk_w;
1614 }
1615 continue;
1616 }
1617 // Wrap before this word if it doesn't fit.
1618 if current_width > 0 && current_width + ww > width {
1619 // Trim trailing space span before breaking.
1620 push_inline_line(
1621 &mut lines,
1622 &mut current_spans,
1623 &mut current_links,
1624 CopyLineSeparator::Space,
1625 );
1626 current_width = 0;
1627 }
1628 record_inline_link(&mut current_links, &word, current_width, ww);
1629 current_spans.push(word.into_span());
1630 current_width += ww;
1631 }
1632
1633 if !current_spans.is_empty() {
1634 push_inline_line(
1635 &mut lines,
1636 &mut current_spans,
1637 &mut current_links,
1638 CopyLineSeparator::Newline,
1639 );
1640 } else if let Some(last) = lines.last_mut() {
1641 last.copy_separator_after = CopyLineSeparator::Newline;
1642 }
1643 if lines.is_empty() {
1644 lines.push(RenderedMarkdownLine {
1645 line: Line::from(""),
1646 links: Vec::new(),
1647 is_code: false,
1648 copy_prefix_width: 0,
1649 copy_separator_after: CopyLineSeparator::Newline,
1650 });
1651 }
1652 lines
1653 }
1654
1655 fn push_inline_line(
1656 lines: &mut Vec<RenderedMarkdownLine>,
1657 spans: &mut Vec<Span<'static>>,
1658 links: &mut Vec<osc8::LineLink>,
1659 copy_separator_after: CopyLineSeparator,
1660 ) {
1661 if let Some(last) = spans.last()
1662 && last.content.as_ref() == " "
1663 {
1664 spans.pop();
1665 }
1666 let visible_width = spans
1667 .iter()
1668 .map(|span| span.content.as_ref().width())
1669 .sum::<usize>();
1670 links.retain(|link| link.col_start < visible_width);
1671 for link in links.iter_mut() {
1672 link.col_end = link.col_end.min(visible_width.saturating_sub(1));
1673 }
1674 lines.push(RenderedMarkdownLine {
1675 line: Line::from(std::mem::take(spans)),
1676 links: std::mem::take(links),
1677 is_code: false,
1678 copy_prefix_width: 0,
1679 copy_separator_after,
1680 });
1681 }
1682
1683 fn record_inline_link(
1684 links: &mut Vec<osc8::LineLink>,
1685 token: &InlineToken,
1686 col_start: usize,
1687 width: usize,
1688 ) {
1689 let Some(target) = token.link_url.as_ref() else {
1690 return;
1691 };
1692 if width == 0 {
1693 return;
1694 }
1695 let col_end = col_start.saturating_add(width).saturating_sub(1);
1696 if let Some(last) = links.last_mut()
1697 && last.target == *target
1698 && last.col_end.saturating_add(1) == col_start
1699 {
1700 last.col_end = col_end;
1701 return;
1702 }
1703 links.push(osc8::LineLink {
1704 col_start,
1705 col_end,
1706 target: target.clone(),
1707 });
1708 }
1709
1710 #[derive(Clone)]
1711 struct InlineToken {
1712 text: String,
1713 style: Style,
1714 link_url: Option<String>,
1715 }
1716
1717 impl InlineToken {
1718 fn new(text: String, style: Style, link_url: Option<String>) -> Self {
1719 Self {
1720 text,
1721 style,
1722 link_url,
1723 }
1724 }
1725
1726 fn span_for(&self, text: String) -> Span<'static> {
1727 Span::styled(text, self.style)
1728 }
1729
1730 fn into_span(self) -> Span<'static> {
1731 Span::styled(self.text, self.style)
1732 }
1733 }
1734
1735 /// Parse an entire line into (text, style) segments, handling **bold**,
1736 /// *italic*, `code`, ~~strikethrough~~, `[text](url)` links, and bare URLs.
1737 fn parse_inline_spans(line: &str, base_style: Style, link_style: Style) -> Vec<InlineToken> {
1738 let bold_style = base_style.add_modifier(Modifier::BOLD);
1739 let italic_style = base_style.add_modifier(Modifier::ITALIC);
1740 let code_style = base_style
1741 .add_modifier(Modifier::ITALIC)
1742 .bg(palette::SURFACE_ELEVATED);
1743 let strike_style = base_style.add_modifier(Modifier::CROSSED_OUT);
1744 let mut out = Vec::new();
1745 let mut rest = line;
1746
1747 while !rest.is_empty() {
1748 // Backslash escape (CommonMark §2.4): `\` before ASCII punctuation
1749 // yields the literal character. Producers escape untrusted text
1750 // (plugin names, paths) this way so it cannot open markup; without
1751 // this arm the backslash itself reached the terminal
1752 // (`computer\-use`, `0\.1\.0` — 0.9.12 defect #21).
1753 if let Some(escaped) = rest.strip_prefix('\\')
1754 && let Some(ch) = escaped.chars().next()
1755 && ch.is_ascii_punctuation()
1756 {
1757 out.push(InlineToken::new(ch.to_string(), base_style, None));
1758 rest = &escaped[ch.len_utf8()..];
1759 continue;
1760 }
1761 // **bold**
1762 if let Some(end) = rest.strip_prefix("**").and_then(|s| s.find("**")) {
1763 let inner = &rest[2..2 + end];
1764 out.push(InlineToken::new(inner.to_string(), bold_style, None));
1765 rest = &rest[2 + end + 2..];
1766 continue;
1767 }
1768 // __bold__
1769 if let Some(end) = rest.strip_prefix("__").and_then(|s| s.find("__")) {
1770 let inner = &rest[2..2 + end];
1771 out.push(InlineToken::new(inner.to_string(), bold_style, None));
1772 rest = &rest[2 + end + 2..];
1773 continue;
1774 }
1775 // *italic*
1776 if rest.starts_with('*')
1777 && !rest.starts_with("**")
1778 && let Some(end) = rest[1..].find('*')
1779 {
1780 let inner = &rest[1..1 + end];
1781 let after = &rest[1 + end + 1..];
1782 // Closing delimiter must not be immediately followed by a
1783 // letter, digit, or underscore (otherwise it's part of an
1784 // identifier like `codewhale_tui`, not italic markup).
1785 if !after.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
1786 out.push(InlineToken::new(inner.to_string(), italic_style, None));
1787 rest = after;
1788 continue;
1789 }
1790 }
1791 // _italic_
1792 if rest.starts_with('_')
1793 && !rest.starts_with("__")
1794 && let Some(end) = rest[1..].find('_')
1795 {
1796 let inner = &rest[1..1 + end];
1797 let after = &rest[1 + end + 1..];
1798 // CommonMark forbids an intraword `_` from opening emphasis and
1799 // requires the opener to be left-flanking (not followed by
1800 // whitespace). Guarding only the closer let `b_p … t_?` italicize
1801 // the prose between two math subscripts (#6042).
1802 let preceded_by_word = line[..line.len() - rest.len()]
1803 .chars()
1804 .next_back()
1805 .is_some_and(char::is_alphanumeric);
1806 let openable =
1807 !preceded_by_word && inner.chars().next().is_some_and(|c| !c.is_whitespace());
1808 // Closing delimiter must not be immediately followed by a
1809 // letter, digit, or underscore.
1810 if openable && !after.starts_with(|c: char| c.is_alphanumeric() || c == '_') {
1811 out.push(InlineToken::new(inner.to_string(), italic_style, None));
1812 rest = after;
1813 continue;
1814 }
1815 }
1816 // `inline code`
1817 if let Some(end) = rest.strip_prefix('`').and_then(|s| s.find('`')) {
1818 let inner = &rest[1..1 + end];
1819 out.push(InlineToken::new(inner.to_string(), code_style, None));
1820 rest = &rest[1 + end + 1..];
1821 continue;
1822 }
1823 // ~~strikethrough~~
1824 if let Some(end) = rest.strip_prefix("~~").and_then(|s| s.find("~~")) {
1825 let inner = &rest[2..2 + end];
1826 out.push(InlineToken::new(inner.to_string(), strike_style, None));
1827 rest = &rest[2 + end + 2..];
1828 continue;
1829 }
1830 // [text](url)
1831 if rest.starts_with('[')
1832 && let Some(bracket_end) = rest.find(']')
1833 {
1834 let text = &rest[1..bracket_end];
1835 let after_bracket = &rest[bracket_end + 1..];
1836 if after_bracket.starts_with('(')
1837 && let Some(paren_end) = after_bracket.find(')')
1838 {
1839 let url = &after_bracket[1..paren_end];
1840 // The runtime toggle gates backend emission, not layout.
1841 // Keeping the same visible label and metadata in both modes
1842 // prevents toggling OSC 8 from reflowing the transcript.
1843 out.push(InlineToken::new(
1844 text.to_string(),
1845 link_style,
1846 normalized_link_target(url),
1847 ));
1848 rest = &after_bracket[paren_end + 1..];
1849 continue;
1850 }
1851 }
1852 // URL: consume until whitespace, then keep trailing punctuation
1853 // visible but outside the hyperlink target.
1854 if rest.starts_with("http://") || rest.starts_with("https://") {
1855 let token_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
1856 let token = &rest[..token_end];
1857 let url_end = trailing_url_end(token);
1858 let url = &token[..url_end];
1859 out.push(InlineToken::new(
1860 url.to_string(),
1861 link_style,
1862 normalized_http_link_target(url),
1863 ));
1864 if url_end < token_end {
1865 out.push(InlineToken::new(
1866 token[url_end..].to_string(),
1867 base_style,
1868 None,
1869 ));
1870 }
1871 rest = &rest[token_end..];
1872 continue;
1873 }
1874 // Plain text: consume until next marker or URL; always advance at least 1 char.
1875 let next = find_next_marker(rest).max(rest.chars().next().map_or(1, |c| c.len_utf8()));
1876 out.push(InlineToken::new(rest[..next].to_string(), base_style, None));
1877 rest = &rest[next..];
1878 }
1879 out
1880 }
1881
1882 /// Normalize an explicit markdown link destination, the only construct the
1883 /// renderer treats as a structured reference. Prose is never scanned for
1884 /// path-shaped text: that linkifies identifiers and version strings, and points
1885 /// at files that do not exist.
1886 fn normalized_link_target(target: &str) -> Option<String> {
1887 normalized_http_link_target(target).or_else(|| normalized_file_link_target(target))
1888 }
1889
1890 /// A destination that is already an absolute path, with or without the `file:`
1891 /// scheme. Relative destinations stay inert because this layer has no workspace
1892 /// root to resolve them against, and a `file://host/...` form is rejected rather
1893 /// than reinterpreted as local. Windows paths must therefore arrive pre-formed
1894 /// as `file:///C:/…`.
1895 fn normalized_file_link_target(target: &str) -> Option<String> {
1896 let path = match target.get(..7) {
1897 Some(prefix) if prefix.eq_ignore_ascii_case("file://") => &target[7..],
1898 _ => target,
1899 };
1900 if !path.starts_with('/') || path.chars().any(|ch| ch.is_whitespace() || ch.is_control()) {
1901 return None;
1902 }
1903 Some(format!("file://{path}"))
1904 }
1905
1906 /// OSC 8 targets produced by markdown are deliberately limited to ordinary
1907 /// web URLs. The browser-opening gesture is user-initiated, but accepting
1908 /// arbitrary schemes here would still turn untrusted model output into a
1909 /// `javascript:` or application-protocol link. Normalize the scheme and reject
1910 /// whitespace/control characters before metadata reaches a frame.
1911 fn normalized_http_link_target(target: &str) -> Option<String> {
1912 let (scheme, rest) = if target
1913 .get(..8)
1914 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
1915 {
1916 ("https://", &target[8..])
1917 } else if target
1918 .get(..7)
1919 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://"))
1920 {
1921 ("http://", &target[7..])
1922 } else {
1923 return None;
1924 };
1925 if rest.is_empty()
1926 || rest.chars().any(|ch| ch.is_whitespace() || ch.is_control())
1927 || rest.split(['/', '?', '#']).next().is_none_or(str::is_empty)
1928 {
1929 return None;
1930 }
1931 Some(format!("{scheme}{rest}"))
1932 }
1933
1934 fn trailing_url_end(candidate: &str) -> usize {
1935 let mut end = candidate.len();
1936 while end > 0 {
1937 let remaining = &candidate[..end];
1938 let Some(ch) = remaining.chars().next_back() else {
1939 break;
1940 };
1941 let trim = matches!(ch, ',' | '.' | ';' | '!' | '\'' | '"')
1942 || matches!(ch, ')' | ']' | '}' | '>')
1943 && has_unmatched_closing_delimiter(remaining, ch);
1944 if !trim {
1945 break;
1946 }
1947 end -= ch.len_utf8();
1948 }
1949 end
1950 }
1951
1952 fn has_unmatched_closing_delimiter(candidate: &str, closing: char) -> bool {
1953 let opening = match closing {
1954 ')' => '(',
1955 ']' => '[',
1956 '}' => '{',
1957 '>' => '<',
1958 _ => return false,
1959 };
1960 candidate.chars().filter(|ch| *ch == closing).count()
1961 > candidate.chars().filter(|ch| *ch == opening).count()
1962 }
1963
1964 /// Find the index of the next inline marker (`**`, `__`, `*`, `_`, `http`)
1965 /// in `s`, or `s.len()` if none found.
1966 fn find_next_marker(s: &str) -> usize {
1967 let mut i = 0;
1968 let bytes = s.as_bytes();
1969 while i < bytes.len() {
1970 let ch_len = s[i..].chars().next().map_or(1, |c| c.len_utf8());
1971 let slice = &s[i..];
1972 if slice.starts_with('\\')
1973 || slice.starts_with("**")
1974 || slice.starts_with("__")
1975 || slice.starts_with("~~")
1976 || slice.starts_with('`')
1977 || slice.starts_with('[')
1978 || (slice.starts_with('*') && !slice.starts_with("**"))
1979 || (slice.starts_with('_') && !slice.starts_with("__"))
1980 || slice.starts_with("http://")
1981 || slice.starts_with("https://")
1982 {
1983 return i;
1984 }
1985 i += ch_len;
1986 }
1987 s.len()
1988 }
1989
1990 fn is_horizontal_rule(line: &str) -> bool {
1991 let stripped: String = line.chars().filter(|c| !c.is_whitespace()).collect();
1992 (stripped.chars().all(|c| c == '-')
1993 || stripped.chars().all(|c| c == '*')
1994 || stripped.chars().all(|c| c == '_'))
1995 && stripped.len() >= 3
1996 }
1997
1998 /// Parse a markdown table row like `| foo | bar |` into trimmed cell strings.
1999 /// Returns `None` for separator rows (`|---|---|`).
2000 fn parse_table_row(line: &str) -> Option<Vec<String>> {
2001 if !line.starts_with('|') {
2002 return None;
2003 }
2004 let inner = line.trim_matches('|');
2005 let cells = split_table_cells(inner);
2006 // Separator row: every non-empty cell is only dashes/colons/spaces
2007 if cells
2008 .iter()
2009 .all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '))
2010 {
2011 return None;
2012 }
2013 Some(cells)
2014 }
2015
2016 fn split_table_cells(inner: &str) -> Vec<String> {
2017 let mut cells = Vec::new();
2018 let mut current = String::new();
2019 let mut in_code = false;
2020 let mut chars = inner.chars().peekable();
2021
2022 while let Some(ch) = chars.next() {
2023 match ch {
2024 '\\' => {
2025 if matches!(chars.peek(), Some('|')) {
2026 current.push('|');
2027 let _ = chars.next();
2028 } else {
2029 current.push(ch);
2030 }
2031 }
2032 '`' => {
2033 in_code = !in_code;
2034 current.push(ch);
2035 }
2036 '|' if !in_code => {
2037 cells.push(current.trim().to_string());
2038 current.clear();
2039 }
2040 _ => current.push(ch),
2041 }
2042 }
2043
2044 cells.push(current.trim().to_string());
2045 cells
2046 }
2047
2048 /// Word-wrap a single cell's text into one or more visual lines, each
2049 /// constrained to `col_width` display columns. Whitespace is the preferred
2050 /// break point; words wider than `col_width` are hard-broken at grapheme
2051 /// boundaries so wrapping always makes progress (no infinite loop on URLs
2052 /// or paths). Returns at least one segment.
2053 fn wrap_cell_text(cell: &str, col_width: usize) -> Vec<String> {
2054 if cell.is_empty() || cell.width() <= col_width {
2055 return vec![cell.to_string()];
2056 }
2057 let mut lines: Vec<String> = Vec::new();
2058 let mut current = String::new();
2059 let mut current_w = 0usize;
2060
2061 for word in cell.split_whitespace() {
2062 let word_w = word.width();
2063 if current_w == 0 {
2064 if word_w > col_width {
2065 push_word_breaking_graphemes(
2066 word,
2067 col_width,
2068 &mut current,
2069 &mut current_w,
2070 &mut lines,
2071 );
2072 } else {
2073 current.push_str(word);
2074 current_w = word_w;
2075 }
2076 } else if current_w + 1 + word_w <= col_width {
2077 current.push(' ');
2078 current.push_str(word);
2079 current_w += 1 + word_w;
2080 } else {
2081 lines.push(std::mem::take(&mut current));
2082 current_w = 0;
2083 if word_w > col_width {
2084 push_word_breaking_graphemes(
2085 word,
2086 col_width,
2087 &mut current,
2088 &mut current_w,
2089 &mut lines,
2090 );
2091 } else {
2092 current.push_str(word);
2093 current_w = word_w;
2094 }
2095 }
2096 }
2097 if !current.is_empty() || lines.is_empty() {
2098 lines.push(current);
2099 }
2100 lines
2101 }
2102
2103 fn render_table_row(cells: &[String], width: usize, base_style: Style) -> Vec<Line<'static>> {
2104 if cells.is_empty() {
2105 return vec![Line::from("")];
2106 }
2107 let col_width = (width.saturating_sub(3 * cells.len() + 1)) / cells.len();
2108 let col_width = col_width.max(4);
2109 let sep_style = Style::default().fg(palette::TEXT_DIM);
2110
2111 // Wrap each cell into one or more visual segments. The row's visual
2112 // height equals the tallest column. Cells that wrap to fewer segments
2113 // get blank-padded continuation lines so column separators stay aligned.
2114 let wrapped: Vec<Vec<String>> = cells.iter().map(|c| wrap_cell_text(c, col_width)).collect();
2115 let row_height = wrapped.iter().map(Vec::len).max().unwrap_or(1).max(1);
2116
2117 let mut lines: Vec<Line<'static>> = Vec::with_capacity(row_height);
2118 for row in 0..row_height {
2119 let mut spans: Vec<Span> = vec![Span::styled("│ ".to_string(), sep_style)];
2120 for (i, cell_segments) in wrapped.iter().enumerate() {
2121 let segment = cell_segments.get(row).map(String::as_str).unwrap_or("");
2122 let cell_spans = parse_inline_spans(segment, base_style, link_style());
2123 let cell_width: usize = cell_spans.iter().map(|token| token.text.width()).sum();
2124 let pad = col_width.saturating_sub(cell_width);
2125 for token in cell_spans {
2126 spans.push(token.into_span());
2127 }
2128 spans.push(Span::raw(" ".repeat(pad)));
2129 if i + 1 < cells.len() {
2130 spans.push(Span::styled(" │ ".to_string(), sep_style));
2131 } else {
2132 spans.push(Span::styled(" │".to_string(), sep_style));
2133 }
2134 }
2135 lines.push(Line::from(spans));
2136 }
2137 lines
2138 }
2139
2140 fn table_col_width(num_cols: usize, term_width: usize) -> usize {
2141 let col_width = (term_width.saturating_sub(3 * num_cols + 1)) / num_cols;
2142 col_width.max(4)
2143 }
2144
2145 fn render_table_border(
2146 num_cols: usize,
2147 col_width: usize,
2148 sep_style: Style,
2149 left: &str,
2150 mid: &str,
2151 right: &str,
2152 ) -> Line<'static> {
2153 let fill = "\u{2500}".repeat(col_width);
2154 let mut s = String::new();
2155 s.push_str(left);
2156 for i in 0..num_cols {
2157 s.push_str(&fill);
2158 if i + 1 < num_cols {
2159 s.push_str(mid);
2160 } else {
2161 s.push_str(right);
2162 }
2163 }
2164 Line::from(Span::styled(s, sep_style))
2165 }
2166
2167 fn render_table_group(blocks: &[Block], width: usize, base_style: Style) -> Vec<Line<'static>> {
2168 let sep_style = Style::default().fg(palette::TEXT_DIM);
2169
2170 let num_cols = blocks
2171 .iter()
2172 .filter_map(|b| match b {
2173 Block::TableRow(cells) => Some(cells.len()),
2174 _ => None,
2175 })
2176 .max()
2177 .unwrap_or(1);
2178
2179 let col_width = table_col_width(num_cols, width);
2180
2181 let mut lines = Vec::new();
2182
2183 // Top border
2184 lines.push(render_table_border(
2185 num_cols,
2186 col_width,
2187 sep_style,
2188 "\u{250C}\u{2500}",
2189 "\u{2500}\u{252C}\u{2500}",
2190 "\u{2500}\u{2510}",
2191 ));
2192
2193 let mid_border = || {
2194 render_table_border(
2195 num_cols,
2196 col_width,
2197 sep_style,
2198 "\u{251C}\u{2500}",
2199 "\u{2500}\u{253C}\u{2500}",
2200 "\u{2500}\u{2524}",
2201 )
2202 };
2203
2204 for i in 0..blocks.len() {
2205 match &blocks[i] {
2206 Block::TableRow(cells) => {
2207 lines.extend(render_table_row(cells, width, base_style));
2208 if i + 1 < blocks.len() && matches!(&blocks[i + 1], Block::TableRow(_)) {
2209 lines.push(mid_border());
2210 }
2211 }
2212 Block::TableSeparator => {
2213 lines.push(mid_border());
2214 }
2215 _ => {}
2216 }
2217 }
2218
2219 // Bottom border
2220 lines.push(render_table_border(
2221 num_cols,
2222 col_width,
2223 sep_style,
2224 "\u{2514}\u{2500}",
2225 "\u{2500}\u{2534}\u{2500}",
2226 "\u{2500}\u{2518}",
2227 ));
2228
2229 lines
2230 }
2231
2232 fn link_style() -> Style {
2233 Style::default()
2234 .fg(palette::WHALE_ACTION)
2235 .add_modifier(Modifier::UNDERLINED)
2236 }
2237
2238 /// Display-column width of one extended grapheme for terminal line-wrap
2239 /// calculations.
2240 ///
2241 /// A tab advances to the next 8-column tab stop. Single-codepoint characters
2242 /// retain the previous one-column fallback (with an override for enclosed
2243 /// alphanumerics that render as 2 columns in CJK terminals). Multi-codepoint
2244 /// emoji, keycaps, and combining sequences use the same string-level width
2245 /// contract as Ratatui, with an override for keycap sequences containing
2246 /// U+20E3 that unicode-width undercounts. (#4479)
2247 fn markdown_grapheme_width(grapheme: &str, col: usize) -> usize {
2248 if grapheme == "\t" {
2249 return 8 - (col % 8); // advance to next 8-column tab stop
2250 }
2251 if let Some(ch) = grapheme.chars().next()
2252 && ch.len_utf8() == grapheme.len()
2253 {
2254 return match ch {
2255 // Enclosed alphanumerics, dingbat circled digits, and circled
2256 // numbers on black square render as 2 columns in CJK terminals
2257 // even though unicode-width reports 1. (#4479)
2258 '\u{2460}'..='\u{24FF}' | '\u{2776}'..='\u{2793}' | '\u{3248}'..='\u{324F}' => 2,
2259 _ => ch.width().unwrap_or(1),
2260 };
2261 }
2262 // Keycap sequences (with or without FE0F) render as 2 columns.
2263 if grapheme.contains('\u{20e3}') {
2264 return 2;
2265 }
2266 grapheme.width()
2267 }
2268
2269 /// Hard-wrap a code line at `width` display columns, preserving all
2270 /// whitespace (including leading indentation). Unlike [`wrap_text`], this
2271 /// does not split on word boundaries — code indentation is semantic.
2272 fn wrap_code_line(line: &str, width: usize) -> Vec<String> {
2273 if width == 0 || line.is_empty() {
2274 return vec![line.to_string()];
2275 }
2276 let mut chunks = Vec::new();
2277 let mut current = String::new();
2278 let mut current_width = 0usize;
2279
2280 for grapheme in line.graphemes(true) {
2281 let grapheme_width = markdown_grapheme_width(grapheme, current_width);
2282 if current_width + grapheme_width > width && !current.is_empty() {
2283 chunks.push(current);
2284 current = String::new();
2285 current_width = 0;
2286 }
2287 current.push_str(grapheme);
2288 current_width += grapheme_width;
2289 }
2290 chunks.push(current);
2291 chunks
2292 }
2293
2294 fn wrap_text(text: &str, width: usize) -> Vec<String> {
2295 if width == 0 {
2296 return vec![text.to_string()];
2297 }
2298 let mut lines = Vec::new();
2299 let mut current = String::new();
2300 let mut current_width = 0;
2301
2302 for word in text.split_whitespace() {
2303 let word_width = word.width();
2304 // If this single word is wider than the entire line, hard-break it
2305 // at grapheme boundaries so wrapping always makes progress
2306 // (#1344, #1351). Without this, long URLs / paths / hashes overflow
2307 // the right edge silently.
2308 if word_width > width {
2309 if !current.is_empty() {
2310 lines.push(std::mem::take(&mut current));
2311 current_width = 0;
2312 }
2313 push_word_breaking_graphemes(word, width, &mut current, &mut current_width, &mut lines);
2314 continue;
2315 }
2316 let additional = if current.is_empty() {
2317 word_width
2318 } else {
2319 word_width + 1
2320 };
2321 if current_width + additional > width && !current.is_empty() {
2322 lines.push(current);
2323 current = word.to_string();
2324 current_width = word_width;
2325 } else {
2326 if !current.is_empty() {
2327 current.push(' ');
2328 current_width += 1;
2329 }
2330 current.push_str(word);
2331 current_width += word_width;
2332 }
2333 }
2334
2335 if current.is_empty() {
2336 lines.push(String::new());
2337 } else {
2338 lines.push(current);
2339 }
2340
2341 lines
2342 }
2343
2344 /// Push graphemes from `word` into `current`, flushing to `lines` when the
2345 /// running display width would exceed `width`. String-level Unicode width
2346 /// matches Ratatui for emoji and combining sequences.
2347 /// Used by `wrap_text` and `wrap_cell_text` so a word longer than the
2348 /// allotted width never silently overflows the right edge.
2349 fn push_word_breaking_graphemes(
2350 word: &str,
2351 width: usize,
2352 current: &mut String,
2353 current_width: &mut usize,
2354 lines: &mut Vec<String>,
2355 ) {
2356 for grapheme in word.graphemes(true) {
2357 let grapheme_width = grapheme.width();
2358 if *current_width + grapheme_width > width && *current_width > 0 {
2359 lines.push(std::mem::take(current));
2360 *current_width = 0;
2361 }
2362 current.push_str(grapheme);
2363 *current_width += grapheme_width;
2364 }
2365 }
2366
2367 #[cfg(test)]
2368 mod tests {
2369 use super::*;
2370 use ratatui::style::Style;
2371
2372 fn visible_lines(lines: &[Line<'static>]) -> Vec<String> {
2373 lines
2374 .iter()
2375 .map(|line| {
2376 line.spans
2377 .iter()
2378 .map(|span| span.content.as_ref())
2379 .collect()
2380 })
2381 .collect()
2382 }
2383
2384 #[test]
2385 fn backslash_escapes_yield_the_literal_punctuation() {
2386 let lines = render_markdown(
2387 "ID: computer\\-use 0\\.1\\.0 \\*not italic\\* trailing\\",
2388 80,
2389 Style::default(),
2390 );
2391 assert_eq!(
2392 visible_lines(&lines),
2393 vec!["ID: computer-use 0.1.0 *not italic* trailing\\"]
2394 );
2395 }
2396
2397 fn rendered_fingerprint(lines: &[RenderedMarkdownLine]) -> Vec<String> {
2398 lines
2399 .iter()
2400 .map(|line| {
2401 format!(
2402 "{:?}|{:?}|{}|{}|{:?}",
2403 line.line,
2404 line.links,
2405 line.is_code,
2406 line.copy_prefix_width,
2407 line.copy_separator_after
2408 )
2409 })
2410 .collect()
2411 }
2412
2413 fn update_incremental_render(
2414 cache: &mut IncrementalMarkdownRenderCache,
2415 rendered: &mut Vec<RenderedMarkdownLine>,
2416 source: &str,
2417 width: u16,
2418 palette_mode: palette::PaletteMode,
2419 verified_append: bool,
2420 ) {
2421 update_incremental_render_with_style(
2422 cache,
2423 rendered,
2424 source,
2425 width,
2426 Style::default(),
2427 palette_mode,
2428 verified_append,
2429 );
2430 }
2431
2432 fn update_incremental_render_with_style(
2433 cache: &mut IncrementalMarkdownRenderCache,
2434 rendered: &mut Vec<RenderedMarkdownLine>,
2435 source: &str,
2436 width: u16,
2437 base_style: Style,
2438 palette_mode: palette::PaletteMode,
2439 verified_append: bool,
2440 ) {
2441 let delta = cache.update(source, width, base_style, palette_mode, verified_append);
2442 rendered.truncate(delta.replace_from);
2443 rendered.extend(delta.lines);
2444 }
2445
2446 #[test]
2447 fn incremental_render_is_exact_and_linear_for_unicode_fences_and_tables() {
2448 let mut cache = IncrementalMarkdownRenderCache::default();
2449 let mut rendered = Vec::new();
2450 let mut source = String::new();
2451 let chunks = 80usize;
2452
2453 for index in 0..chunks {
2454 source.push_str(&format!(
2455 "## 段落 {index}\nUnicode e\u{301} 世界 🚀\n```rust\nlet 値_{index}: usize = {index}; // 注釈\n```\n| key | value |\n|---|---|\n| {index} | 世界 |\n\n"
2456 ));
2457 update_incremental_render(
2458 &mut cache,
2459 &mut rendered,
2460 &source,
2461 96,
2462 palette::PaletteMode::Dark,
2463 index > 0,
2464 );
2465 let cold = render_markdown_tagged_with_palette(
2466 &source,
2467 96,
2468 Style::default(),
2469 palette::PaletteMode::Dark,
2470 );
2471 assert_eq!(
2472 rendered_fingerprint(&rendered),
2473 rendered_fingerprint(&cold),
2474 "incremental output diverged after chunk {index}"
2475 );
2476 }
2477
2478 let work = cache.work();
2479 let parsed = reference_parse(&source);
2480 assert_eq!(work.classified_lines as usize, source.lines().count());
2481 assert_eq!(work.stable_blocks_rendered as usize, parsed.blocks.len());
2482 assert_eq!(work.tail_blocks_rendered, 0);
2483 assert_eq!(work.invalidations, 1);
2484 }
2485
2486 #[test]
2487 fn incremental_render_invalidates_on_mutation_width_theme_and_style() {
2488 let mut cache = IncrementalMarkdownRenderCache::default();
2489 let mut rendered = Vec::new();
2490 let mut source = "alpha\n```rust\nlet value = 1;\n```\n".to_string();
2491 update_incremental_render(
2492 &mut cache,
2493 &mut rendered,
2494 &source,
2495 80,
2496 palette::PaletteMode::Dark,
2497 false,
2498 );
2499
2500 source.push_str("tail 世界\n");
2501 update_incremental_render(
2502 &mut cache,
2503 &mut rendered,
2504 &source,
2505 80,
2506 palette::PaletteMode::Dark,
2507 true,
2508 );
2509
2510 source.replace_range(..5, "ALPHA");
2511 update_incremental_render(
2512 &mut cache,
2513 &mut rendered,
2514 &source,
2515 80,
2516 palette::PaletteMode::Dark,
2517 false,
2518 );
2519 let mutated = render_markdown_tagged_with_palette(
2520 &source,
2521 80,
2522 Style::default(),
2523 palette::PaletteMode::Dark,
2524 );
2525 assert_eq!(
2526 rendered_fingerprint(&rendered),
2527 rendered_fingerprint(&mutated)
2528 );
2529
2530 update_incremental_render(
2531 &mut cache,
2532 &mut rendered,
2533 &source,
2534 37,
2535 palette::PaletteMode::Dark,
2536 true,
2537 );
2538 update_incremental_render(
2539 &mut cache,
2540 &mut rendered,
2541 &source,
2542 37,
2543 palette::PaletteMode::Light,
2544 true,
2545 );
2546
2547 let changed_style = Style::default().add_modifier(Modifier::ITALIC);
2548 update_incremental_render_with_style(
2549 &mut cache,
2550 &mut rendered,
2551 &source,
2552 37,
2553 changed_style,
2554 palette::PaletteMode::Light,
2555 true,
2556 );
2557 let rethemed = render_markdown_tagged_with_palette(
2558 &source,
2559 37,
2560 changed_style,
2561 palette::PaletteMode::Light,
2562 );
2563 assert_eq!(
2564 rendered_fingerprint(&rendered),
2565 rendered_fingerprint(&rethemed)
2566 );
2567 assert_eq!(cache.work().invalidations, 5);
2568 }
2569
2570 #[test]
2571 fn incremental_render_drops_committed_source_without_a_large_answer_cliff() {
2572 let line = format!("{}\n", "x".repeat(16 * 1024));
2573 let mut source = String::new();
2574 let mut cache = IncrementalMarkdownRenderCache::default();
2575 let mut rendered = Vec::new();
2576
2577 for index in 0..80 {
2578 source.push_str(&line);
2579 update_incremental_render(
2580 &mut cache,
2581 &mut rendered,
2582 &source,
2583 u16::MAX,
2584 palette::PaletteMode::Dark,
2585 index > 0,
2586 );
2587 assert_eq!(cache.retained_source_bytes(), 0);
2588 }
2589
2590 assert!(source.len() > 1024 * 1024);
2591 assert_eq!(cache.retained_source_bytes(), 0);
2592 assert_eq!(cache.work().classified_lines, 80);
2593 assert_eq!(cache.work().stable_blocks_rendered, 80);
2594 assert_eq!(cache.work().invalidations, 1);
2595 }
2596
2597 #[test]
2598 fn verified_append_resume_rejects_a_rewritten_committed_prefix() {
2599 // #6196: the streaming cache consumes the latex-rendered projection
2600 // of the raw stream. While a `\[` display block is open,
2601 // `render_latex_in_text` passes it through as literal text and the
2602 // incremental cache commits those lines. When the closing `\]`
2603 // finally arrives the rendered form rewrites the *committed* bytes
2604 // even though the raw stream only appended, so the append receipt
2605 // stays valid and only a content-aware committed-prefix check can
2606 // see the rewrite. A length-only check resumed from the stale lines
2607 // and the un-rendered opener stuck in the cell forever. The strings
2608 // below are the projections the latex layer produces for that
2609 // sequence.
2610 let mut cache = IncrementalMarkdownRenderCache::default();
2611 let mut rendered = Vec::new();
2612 // Beat 1: raw stream `para\n\[ \alpha\n` — the open block passes
2613 // through literally and its complete line is committed.
2614 update_incremental_render(
2615 &mut cache,
2616 &mut rendered,
2617 "para\n\\[ \\alpha\n",
2618 80,
2619 palette::PaletteMode::Dark,
2620 false,
2621 );
2622 assert_eq!(cache.work().invalidations, 1);
2623
2624 // Beat 2: the raw stream appended `\]` plus more prose; the
2625 // projection rewrites the committed line and grows past its old
2626 // length, so the resume guard cannot rely on length alone.
2627 let closed = "para\nα\nthe identity holds for every pair of terms\n";
2628 update_incremental_render(
2629 &mut cache,
2630 &mut rendered,
2631 closed,
2632 80,
2633 palette::PaletteMode::Dark,
2634 true,
2635 );
2636 assert_eq!(
2637 cache.work().invalidations,
2638 2,
2639 "rewriting committed bytes must invalidate the resume"
2640 );
2641 let cold = render_markdown_tagged_with_palette(
2642 closed,
2643 80,
2644 Style::default(),
2645 palette::PaletteMode::Dark,
2646 );
2647 assert_eq!(
2648 rendered_fingerprint(&rendered),
2649 rendered_fingerprint(&cold),
2650 "the re-render must match a cold render of the closed form"
2651 );
2652 assert!(
2653 !rendered.iter().any(|line| {
2654 line.line
2655 .spans
2656 .iter()
2657 .any(|span| span.content.contains("\\["))
2658 }),
2659 "the stale literal latex opener must not survive the close"
2660 );
2661 }
2662
2663 #[test]
2664 fn underscores_inside_identifiers_render_as_literal_text() {
2665 // Regression for PR #1455 / @tiger-dog: previously the inline
2666 // markdown parser ate the underscore in `codewhale_tui` because
2667 // it matched the `_italic_` pattern without a CommonMark-style
2668 // boundary check. The closing `_` followed by `t` (a letter)
2669 // must now be treated as part of the identifier, not as
2670 // markup. The same rule applies to `*` so identifiers like
2671 // `crate*foo` round-trip cleanly.
2672 let cases = [
2673 "crate codewhale_tui handles approvals",
2674 "see foo_bar_baz for details",
2675 "look at *not_emphasised*tail",
2676 ];
2677 for source in cases {
2678 let parsed = parse(source);
2679 let rendered: String = render_parsed(&parsed, 80, Style::default())
2680 .iter()
2681 .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
2682 .collect();
2683 // The original identifier (with underscores intact) must
2684 // appear in the rendered output. We don't assert on style
2685 // here — that's an implementation detail; we assert on
2686 // the user-visible character sequence.
2687 for token in source.split_whitespace().filter(|t| t.contains('_')) {
2688 assert!(
2689 rendered.contains(token),
2690 "identifier {token:?} must survive markdown rendering of {source:?}; got {rendered:?}"
2691 );
2692 }
2693 }
2694 }
2695
2696 #[test]
2697 fn underscore_emphasis_does_not_open_mid_word_across_math_subscripts() {
2698 // #6042: the closing-side guard alone let `b_p` open emphasis and
2699 // `t_?` close it, italicizing 60 characters of prose. CommonMark
2700 // forbids an intraword `_` from opening emphasis.
2701 let source = "[t_, b_p]. Actually — hold on, do we even tile all the way from t_?";
2702 let lines = render_parsed(&parse(source), 200, Style::default());
2703 let italic: Vec<&str> = lines
2704 .iter()
2705 .flat_map(|line| line.spans.iter())
2706 .filter(|span| span.style.add_modifier.contains(Modifier::ITALIC))
2707 .map(|span| span.content.as_ref())
2708 .collect();
2709 assert!(
2710 italic.is_empty(),
2711 "math subscripts must not open emphasis; italic spans: {italic:?}"
2712 );
2713
2714 // A properly flanked `_italic_` run still renders italic…
2715 let flanked = render_parsed(&parse("an _emphasised_ word"), 80, Style::default());
2716 assert!(
2717 flanked
2718 .iter()
2719 .flat_map(|line| line.spans.iter())
2720 .any(|span| span.style.add_modifier.contains(Modifier::ITALIC)
2721 && span.content.as_ref() == "emphasised"),
2722 "a flanked _italic_ run must still render italic"
2723 );
2724 // …and after punctuation it still opens.
2725 let after_punct = render_parsed(&parse("word (_also_)"), 80, Style::default());
2726 assert!(
2727 after_punct
2728 .iter()
2729 .flat_map(|line| line.spans.iter())
2730 .any(|span| span.style.add_modifier.contains(Modifier::ITALIC)
2731 && span.content.as_ref() == "also"),
2732 "a _ run after punctuation must still open"
2733 );
2734 }
2735
2736 #[test]
2737 fn render_markdown_matches_parse_then_render() {
2738 let source = "# Title\n\nA paragraph with a https://example.com link.\n\n- one\n- two\n```\ncode\n```";
2739 let direct = render_markdown(source, 80, Style::default())
2740 .iter()
2741 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
2742 .collect::<String>();
2743 let parsed = parse(source);
2744 let two_step = render_parsed(&parsed, 80, Style::default())
2745 .iter()
2746 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
2747 .collect::<String>();
2748 assert_eq!(direct, two_step);
2749 }
2750
2751 #[test]
2752 fn render_plain_text_preserves_literal_markdown_and_spacing() {
2753 let source = " # heading\n- item\n \nhello world\n";
2754 let lines = render_plain_text(source, 80, Style::default());
2755
2756 assert_eq!(
2757 visible_lines(&lines),
2758 vec![" # heading", "- item", " ", "hello world", ""]
2759 );
2760 }
2761
2762 #[test]
2763 fn render_plain_text_wraps_without_collapsing_spaces() {
2764 let source = "alpha beta gamma";
2765 let lines = render_plain_text(source, 12, Style::default());
2766 for width in rendered_widths(&lines) {
2767 assert!(width <= 12, "rendered width {width} exceeds budget");
2768 }
2769
2770 let combined = visible_lines(&lines).join("");
2771 assert_eq!(combined, source);
2772 }
2773
2774 #[test]
2775 fn render_plain_text_breaks_overlong_words() {
2776 let source = "x".repeat(40);
2777 let lines = render_plain_text(&source, 9, Style::default());
2778 for width in rendered_widths(&lines) {
2779 assert!(width <= 9, "rendered width {width} exceeds budget");
2780 }
2781
2782 let combined = visible_lines(&lines).join("");
2783 assert_eq!(combined, source);
2784 }
2785
2786 #[test]
2787 fn parse_is_width_independent() {
2788 // Same source, two parses, must produce identical AST. (Sanity:
2789 // parse must not depend on hidden global state like terminal width.)
2790 let source = "Hello\n\n## Heading\n- list\n";
2791 let a = parse(source);
2792 let b = parse(source);
2793 assert_eq!(a, b);
2794 }
2795
2796 #[test]
2797 fn render_parsed_word_wrap_changes_with_width() {
2798 // The same AST must produce different layouts at different widths;
2799 // otherwise the split is decorative, not functional.
2800 let parsed = parse("alpha beta gamma delta epsilon zeta");
2801 let wide = render_parsed(&parsed, 80, Style::default());
2802 let narrow = render_parsed(&parsed, 10, Style::default());
2803 assert!(
2804 narrow.len() > wide.len(),
2805 "narrow should produce more lines"
2806 );
2807 }
2808
2809 #[test]
2810 fn parse_invocations_increment() {
2811 // Counter is thread-local, so concurrent tests calling `parse()`
2812 // can't pollute each other.
2813 reset_parse_invocation_count();
2814 let _ = parse("hello\n");
2815 let _ = parse("world\n");
2816 assert_eq!(parse_invocation_count(), 2);
2817 }
2818
2819 #[test]
2820 fn render_parsed_does_not_call_parse() {
2821 // Width-only changes must hit only the render path. This is the
2822 // perf invariant CX#6 was filed for.
2823 let parsed = parse("multiline\nsource\nwith several\nlines\n");
2824 reset_parse_invocation_count();
2825 let _ = render_parsed(&parsed, 80, Style::default());
2826 let _ = render_parsed(&parsed, 40, Style::default());
2827 let _ = render_parsed(&parsed, 20, Style::default());
2828 assert_eq!(
2829 parse_invocation_count(),
2830 0,
2831 "render_parsed must not call parse"
2832 );
2833 }
2834
2835 // -----------------------------------------------------------------
2836 // #3897 — streaming re-parse is incremental, not quadratic
2837 // -----------------------------------------------------------------
2838
2839 /// Markdown corpus chosen to hit every carry-state transition the parser
2840 /// has: fences opened and closed across chunk boundaries, an unterminated
2841 /// fence, headings, nested lists, tables, rules, blanks, CRLF, and CJK.
2842 fn streaming_corpus() -> Vec<&'static str> {
2843 vec![
2844 "# Title\n\nSome prose that wraps.\n\n- alpha\n- beta\n",
2845 "text\n```rust\nlet x = 1;\nlet y = 2;\n```\nafter\n",
2846 "```\nunterminated fence never closes\nstill inside\n",
2847 "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n\ndone\n",
2848 "1. one\n2. two\n * nested\n\n## Sub\n\n***\n",
2849 "混合 CJK 内容\n\n```python\nprint(\"中文\")\n```\n尾部\n",
2850 "crlf lines\r\nsecond\r\n\r\n```go\nfmt.Println()\r\n```\r\n",
2851 "no trailing newline at all",
2852 "",
2853 ]
2854 }
2855
2856 /// The acceptance guarantee: streaming a message chunk by chunk produces,
2857 /// at every intermediate prefix, exactly what a full re-parse of that
2858 /// prefix produces. Byte-for-byte on the AST, so a divergence in any field
2859 /// of any block fails here rather than showing up as a render artifact.
2860 #[test]
2861 fn incremental_parse_matches_a_full_reparse_at_every_prefix() {
2862 for source in streaming_corpus() {
2863 // Grow one byte at a time (respecting char boundaries) — the
2864 // worst case for a parser that commits too eagerly.
2865 for end in 0..=source.len() {
2866 if !source.is_char_boundary(end) {
2867 continue;
2868 }
2869 let prefix = &source[..end];
2870 let streamed = parse(prefix);
2871
2872 // A cold parser is the reference: no memo, no resumption.
2873 let mut cold = ParseState::default();
2874 cold.commit_complete_lines(prefix);
2875 let reference = cold.snapshot(prefix);
2876
2877 assert_eq!(
2878 streamed, reference,
2879 "prefix {end} of {source:?} diverged from a full re-parse"
2880 );
2881 }
2882 }
2883 }
2884
2885 /// The performance guarantee: work per chunk must not grow with the
2886 /// message. Counted in lines actually classified, which is the quantity
2887 /// that was quadratic — the old code re-classified every line on every
2888 /// chunk.
2889 #[test]
2890 fn streaming_does_not_reclassify_committed_lines() {
2891 let chunk = "a line of prose\n";
2892 let chunks = 400;
2893
2894 let mut content = String::new();
2895 let mut state = ParseState::default();
2896 let mut total_committed = 0usize;
2897
2898 for _ in 0..chunks {
2899 content.push_str(chunk);
2900 assert!(
2901 state.can_resume_from(&content),
2902 "an append-only stream must always be resumable"
2903 );
2904 let before = state.blocks.len();
2905 state.commit_complete_lines(&content);
2906 total_committed += state.blocks.len() - before;
2907 }
2908
2909 // Quadratic would be chunks * (chunks + 1) / 2 = 80,200 classifications.
2910 assert_eq!(
2911 total_committed, chunks,
2912 "each line must be classified exactly once across the whole stream"
2913 );
2914 assert_eq!(state.blocks.len(), chunks);
2915 }
2916
2917 /// Resumption is verified, never assumed. Content that does not extend the
2918 /// committed prefix must fall back to a full parse rather than splice
2919 /// unrelated blocks together.
2920 #[test]
2921 fn a_changed_prefix_is_not_resumable() {
2922 let mut state = ParseState::default();
2923 state.commit_complete_lines("first line\nsecond line\n");
2924
2925 assert!(state.can_resume_from("first line\nsecond line\nthird\n"));
2926 // Earlier bytes rewritten.
2927 assert!(!state.can_resume_from("FIRST line\nsecond line\nthird\n"));
2928 // Buffer shrank (a different, shorter cell).
2929 assert!(!state.can_resume_from("first line\n"));
2930 // Entirely unrelated content.
2931 assert!(!state.can_resume_from("something else\n"));
2932 }
2933
2934 /// Interleaving two different sources through the shared memo must not
2935 /// contaminate either — this is the multi-cell render-loop case.
2936 #[test]
2937 fn interleaved_sources_do_not_contaminate_each_other() {
2938 let a = "# Alpha\n\nalpha body\n";
2939 let b = "```rust\nlet b = 1;\n```\n";
2940 for _ in 0..5 {
2941 assert_eq!(parse(a), reference_parse(a));
2942 assert_eq!(parse(b), reference_parse(b));
2943 }
2944 }
2945
2946 fn reference_parse(content: &str) -> ParsedMarkdown {
2947 let mut cold = ParseState::default();
2948 cold.commit_complete_lines(content);
2949 cold.snapshot(content)
2950 }
2951
2952 #[test]
2953 fn fenced_code_block_collected_in_parse() {
2954 let parsed = parse("text\n```rust\ncode line one\ncode line two\n```\nmore\n");
2955 let blocks = &parsed.blocks;
2956 // text paragraph, two code lines, more paragraph (fences are dropped)
2957 let code_lines: Vec<_> = blocks
2958 .iter()
2959 .filter_map(|b| match b {
2960 Block::Code {
2961 line,
2962 language,
2963 block_id,
2964 } => Some((line.as_str(), language.as_deref(), *block_id)),
2965 _ => None,
2966 })
2967 .collect();
2968 assert_eq!(
2969 code_lines,
2970 vec![
2971 ("code line one", Some("rust"), 1),
2972 ("code line two", Some("rust"), 1),
2973 ]
2974 );
2975 }
2976
2977 #[test]
2978 fn adjacent_code_fences_keep_distinct_highlighter_state() {
2979 let parsed = parse("```rust\n/* open\n```\n```rust\nlet x = 1;\n```\n");
2980 let ids = parsed
2981 .blocks
2982 .iter()
2983 .filter_map(|block| match block {
2984 Block::Code { block_id, .. } => Some(*block_id),
2985 _ => None,
2986 })
2987 .collect::<Vec<_>>();
2988 assert_eq!(ids, vec![1, 2]);
2989 }
2990
2991 #[test]
2992 fn rust_fence_renders_multiple_syntax_foregrounds_without_reserved_rgb() {
2993 let rendered = render_markdown_tagged(
2994 "```rust\nfn main() {\n let answer: u32 = 42; // comment\n}\n```",
2995 100,
2996 Style::default(),
2997 );
2998 let colors = rendered
2999 .iter()
3000 .flat_map(|line| line.line.spans.iter())
3001 .filter_map(|span| span.style.fg)
3002 .collect::<std::collections::HashSet<_>>();
3003 assert!(colors.len() > 1, "expected syntax colors, got: {colors:?}");
3004 for color in colors {
3005 assert_ne!(color, palette::WHALE_HUMAN);
3006 assert_ne!(color, palette::WHALE_LIVE);
3007 assert_ne!(color, palette::WHALE_ACTION);
3008 assert_ne!(color, palette::WHALE_ERROR);
3009 }
3010 }
3011
3012 #[test]
3013 fn syntax_colors_use_existing_depth_quantizer_and_grayscale_path() {
3014 assert!(matches!(
3015 syntax_rgb_to_terminal_color(
3016 120,
3017 80,
3018 200,
3019 palette::PaletteMode::Dark,
3020 palette::ColorDepth::Ansi256,
3021 ),
3022 Color::Indexed(_)
3023 ));
3024 assert!(matches!(
3025 syntax_rgb_to_terminal_color(
3026 120,
3027 80,
3028 200,
3029 palette::PaletteMode::Dark,
3030 palette::ColorDepth::Ansi16,
3031 ),
3032 Color::Black
3033 | Color::Red
3034 | Color::Green
3035 | Color::Yellow
3036 | Color::Blue
3037 | Color::Magenta
3038 | Color::Cyan
3039 | Color::Gray
3040 | Color::DarkGray
3041 | Color::LightRed
3042 | Color::LightGreen
3043 | Color::LightYellow
3044 | Color::LightBlue
3045 | Color::LightMagenta
3046 | Color::LightCyan
3047 | Color::White
3048 ));
3049 let gray = syntax_rgb_to_terminal_color(
3050 120,
3051 80,
3052 200,
3053 palette::PaletteMode::Grayscale,
3054 palette::ColorDepth::TrueColor,
3055 );
3056 assert!(matches!(gray, Color::Rgb(r, g, b) if r == g && g == b));
3057 }
3058
3059 #[test]
3060 fn syntax_assets_are_lazy_singletons_and_explicit_modes_select_themes() {
3061 assert!(std::ptr::eq(syntax_set(), syntax_set()));
3062 assert!(std::ptr::eq(theme_set(), theme_set()));
3063 assert!(!std::ptr::eq(
3064 selected_syntax_theme(palette::PaletteMode::Dark),
3065 selected_syntax_theme(palette::PaletteMode::Light),
3066 ));
3067 }
3068
3069 #[test]
3070 fn depth_quantization_cannot_reintroduce_reserved_semantic_colors() {
3071 let reserved = [
3072 palette::WHALE_HUMAN,
3073 palette::WHALE_LIVE,
3074 palette::WHALE_ACTION,
3075 palette::WHALE_ERROR,
3076 ];
3077 for depth in [
3078 palette::ColorDepth::TrueColor,
3079 palette::ColorDepth::Ansi256,
3080 palette::ColorDepth::Ansi16,
3081 ] {
3082 let reserved_at_depth = reserved.map(|color| palette::adapt_color(color, depth));
3083 for semantic in reserved {
3084 let Color::Rgb(r, g, b) = semantic else {
3085 panic!("reserved syntax guard expects RGB semantic colors");
3086 };
3087 let syntax =
3088 syntax_rgb_to_terminal_color(r, g, b, palette::PaletteMode::Dark, depth);
3089 assert!(
3090 !reserved_at_depth.contains(&syntax),
3091 "{syntax:?} reintroduced a reserved color at {depth:?}"
3092 );
3093 }
3094 }
3095 }
3096
3097 #[test]
3098 fn code_block_indentation_is_preserved_in_render() {
3099 // Leading whitespace in code blocks is semantic — indented lines must
3100 // not be stripped to column zero when rendered.
3101 let md = "```\nfn main() {\n println!(\"hi\");\n}\n```\n";
3102 let lines = render_markdown(md, 80, Style::default());
3103 let text: Vec<String> = lines
3104 .iter()
3105 .map(|l| {
3106 l.spans
3107 .iter()
3108 .map(|s| s.content.as_ref())
3109 .collect::<String>()
3110 })
3111 .collect();
3112 // The indented line must start with spaces (the 2-space code prefix
3113 // plus the 4-space source indentation).
3114 let indented = text
3115 .iter()
3116 .find(|t| t.contains("println"))
3117 .expect("should find println line");
3118 assert!(
3119 indented.starts_with(" "),
3120 "expected 6+ leading spaces (2 block prefix + 4 indent), got: {indented:?}"
3121 );
3122 }
3123
3124 #[test]
3125 fn wrap_code_line_preserves_leading_whitespace() {
3126 // A short line must not be modified.
3127 assert_eq!(wrap_code_line(" let x = 1;", 80), vec![" let x = 1;"]);
3128
3129 // A line that exceeds the width must be hard-wrapped, keeping the
3130 // leading whitespace on the first chunk.
3131 let chunks = wrap_code_line(" abcdefgh", 8);
3132 assert_eq!(chunks[0], " abcd", "first chunk keeps leading spaces");
3133 assert_eq!(chunks[1], "efgh");
3134
3135 // Empty line produces one empty chunk.
3136 assert_eq!(wrap_code_line("", 80), vec![""]);
3137 }
3138
3139 #[test]
3140 fn wrap_code_line_tab_counts_toward_width() {
3141 // tab (8 cols) + "xy" (2 cols) = 10 ≤ 10 — fits on one line.
3142 let chunks = wrap_code_line("\txy", 10);
3143 assert_eq!(chunks, vec!["\txy"], "tab + 2 chars fits in width 10");
3144
3145 // tab (8 cols) + "x" (1 col) = 9 ≤ 9 — "x" fits; "y" overflows.
3146 let chunks = wrap_code_line("\txy", 9);
3147 assert_eq!(chunks[0], "\tx", "tab + first char fits exactly");
3148 assert_eq!(chunks[1], "y", "second char wraps");
3149
3150 // tab alone (8 cols) fits in width 8; the next "x" overflows.
3151 let chunks = wrap_code_line("\tx", 8);
3152 assert_eq!(chunks[0], "\t");
3153 assert_eq!(chunks[1], "x");
3154 }
3155
3156 #[test]
3157 fn markdown_grapheme_width_uses_tab_stop_and_string_width() {
3158 // At column 0 a tab fills to column 8.
3159 assert_eq!(markdown_grapheme_width("\t", 0), 8);
3160 // At column 4 a tab fills to column 8 (4 remaining).
3161 assert_eq!(markdown_grapheme_width("\t", 4), 4);
3162 // At column 8 a tab fills to the next stop at 16 (8 columns).
3163 assert_eq!(markdown_grapheme_width("\t", 8), 8);
3164 // Regular ASCII is 1.
3165 assert_eq!(markdown_grapheme_width("a", 0), 1);
3166 // A fully-qualified keycap is one two-column grapheme.
3167 assert_eq!(markdown_grapheme_width("1\u{fe0f}\u{20e3}", 0), 2);
3168 }
3169
3170 #[test]
3171 fn ordered_and_unordered_list_items_parse() {
3172 let parsed = parse("- alpha\n* beta\n1. gamma\n");
3173 let items: Vec<_> = parsed
3174 .blocks
3175 .iter()
3176 .filter_map(|b| match b {
3177 Block::ListItem { bullet, text } => Some((bullet.as_str(), text.as_str())),
3178 _ => None,
3179 })
3180 .collect();
3181 assert_eq!(items, vec![("-", "alpha"), ("-", "beta"), ("1.", "gamma")]);
3182 }
3183
3184 #[test]
3185 fn blockquote_lines_parse_with_depth() {
3186 let parsed =
3187 parse("> hello\n>\n>> nested\n> > spaced\n>no-space\n>\t tabbed\nlone > arrow\n");
3188 let quotes: Vec<_> = parsed
3189 .blocks
3190 .iter()
3191 .filter_map(|b| match b {
3192 Block::Quote { depth, text } => Some((*depth, text.as_str())),
3193 _ => None,
3194 })
3195 .collect();
3196 assert_eq!(
3197 quotes,
3198 vec![
3199 (1, "hello"),
3200 (1, ""),
3201 (2, "nested"),
3202 (2, "spaced"),
3203 (1, "no-space"),
3204 (1, "tabbed"),
3205 ]
3206 );
3207 // `lone > arrow` starts with prose, so it stays a paragraph.
3208 assert_eq!(parsed.blocks.len(), 7);
3209 }
3210
3211 #[test]
3212 fn code_fence_contains_quote_lines_untouched() {
3213 // Fenced-code lines are collected before blockquote classification, so
3214 // `>` inside a fence must stay literal code content.
3215 let parsed = parse("```\n> not a quote\n\n> but this is\n```\n");
3216 let code: Vec<_> = parsed
3217 .blocks
3218 .iter()
3219 .filter_map(|b| match b {
3220 Block::Code { line, .. } => Some(line.as_str()),
3221 _ => None,
3222 })
3223 .collect();
3224 assert_eq!(code, vec!["> not a quote", "", "> but this is"]);
3225 assert!(
3226 parsed
3227 .blocks
3228 .iter()
3229 .all(|b| !matches!(b, Block::Quote { .. })),
3230 "lines inside a fence must stay code, never quotes"
3231 );
3232 }
3233
3234 #[test]
3235 fn four_backtick_fence_keeps_shorter_fence_and_quotes_as_code() {
3236 // A ```` opener must not be closed by a shorter ``` line: the shorter
3237 // fence and any `>` lines after it stay code content (CommonMark
3238 // fence-length rule), and the block only closes on a fence >= opener.
3239 let parsed = parse("````\n```\n> still code\n`````\n> now a quote\n");
3240 let code: Vec<_> = parsed
3241 .blocks
3242 .iter()
3243 .filter_map(|b| match b {
3244 Block::Code { line, .. } => Some(line.as_str()),
3245 _ => None,
3246 })
3247 .collect();
3248 assert_eq!(code, vec!["```", "> still code"]);
3249 let quotes: Vec<_> = parsed
3250 .blocks
3251 .iter()
3252 .filter_map(|b| match b {
3253 Block::Quote { text, .. } => Some(text.as_str()),
3254 _ => None,
3255 })
3256 .collect();
3257 assert_eq!(quotes, vec!["now a quote"]);
3258 }
3259
3260 #[test]
3261 fn longer_fence_closes_shorter_opener() {
3262 // CommonMark: a closing fence may be longer than the opener; only the
3263 // opener's length is the minimum.
3264 let parsed = parse("```\ncode\n````\nplain\n");
3265 let code: Vec<_> = parsed
3266 .blocks
3267 .iter()
3268 .filter_map(|b| match b {
3269 Block::Code { line, .. } => Some(line.as_str()),
3270 _ => None,
3271 })
3272 .collect();
3273 assert_eq!(code, vec!["code"]);
3274 let paragraphs: Vec<_> = parsed
3275 .blocks
3276 .iter()
3277 .filter_map(|b| match b {
3278 Block::Paragraph { text } => Some(text.as_str()),
3279 _ => None,
3280 })
3281 .collect();
3282 assert_eq!(paragraphs, vec!["plain"]);
3283 }
3284
3285 #[test]
3286 fn backticks_with_info_do_not_close_an_open_fence() {
3287 let parsed = parse("```\n```rust\n> still code\n```\n");
3288 let code: Vec<_> = parsed
3289 .blocks
3290 .iter()
3291 .filter_map(|block| match block {
3292 Block::Code { line, .. } => Some(line.as_str()),
3293 _ => None,
3294 })
3295 .collect();
3296
3297 assert_eq!(code, vec!["```rust", "> still code"]);
3298 assert!(
3299 parsed
3300 .blocks
3301 .iter()
3302 .all(|block| !matches!(block, Block::Quote { .. }))
3303 );
3304 }
3305
3306 #[test]
3307 fn blockquote_renders_rail_and_inline_formatting() {
3308 let rendered = render_markdown_tagged(
3309 "> **bold** `code` and see https://example.com",
3310 80,
3311 Style::default(),
3312 );
3313 assert_eq!(
3314 tagged_visible(&rendered),
3315 vec!["│ bold code and see https://example.com"]
3316 );
3317 let spans = &rendered[0].line.spans;
3318 assert_eq!(spans[0].content, "│ ");
3319 assert!(
3320 spans
3321 .iter()
3322 .any(|span| span.content == "bold"
3323 && span.style.add_modifier.contains(Modifier::BOLD)),
3324 "inline bold must survive inside a quote"
3325 );
3326 assert!(
3327 spans
3328 .iter()
3329 .any(|span| span.content == "code"
3330 && span.style.bg == Some(palette::SURFACE_ELEVATED)),
3331 "inline code is styled distinctly from plain text"
3332 );
3333 // First-row rail is stripped by the transcript rail-scan along with
3334 // the assistant glyph, so copy must report 0 here (mirror list items)
3335 // — reporting the rail width would strip it twice and lose text.
3336 assert_eq!(rendered[0].copy_prefix_width, 0);
3337 assert_eq!(
3338 rendered[0].links,
3339 vec![osc8::LineLink {
3340 col_start: 20,
3341 col_end: 38,
3342 target: "https://example.com".to_string(),
3343 }]
3344 );
3345 }
3346
3347 #[test]
3348 fn nested_blockquote_renders_multiple_rails_capped() {
3349 let rendered =
3350 render_markdown_tagged(">>> deep\n>>>>>>>>>> too deep\n", 80, Style::default());
3351 assert_eq!(
3352 tagged_visible(&rendered),
3353 vec!["│ │ │ deep", "│ │ │ │ too deep"]
3354 );
3355 assert_eq!(
3356 rendered[0]
3357 .line
3358 .spans
3359 .iter()
3360 .take(3)
3361 .map(|span| span.content.as_ref())
3362 .collect::<Vec<_>>(),
3363 vec!["│ ", "│ ", "│ "],
3364 "each rail must remain independently discoverable by selection copy"
3365 );
3366 }
3367
3368 #[test]
3369 fn blockquote_wraps_with_continuation_rail_indent() {
3370 let source = "> alpha beta gamma delta epsilon zeta";
3371 let rendered = render_markdown_tagged(source, 12, Style::default());
3372 let visible = tagged_visible(&rendered);
3373 assert!(visible.len() > 1, "fixture must wrap: {visible:?}");
3374 assert!(visible[0].starts_with("│ "), "first row starts with rail");
3375 // Copy-prefix accounting: first row reports 0 (rail-scan strips it),
3376 // continuation rows report the rail width (plain spaces are not
3377 // stripped by the rail-scan).
3378 assert_eq!(rendered[0].copy_prefix_width, 0);
3379 for row in rendered.iter().skip(1) {
3380 assert_eq!(row.copy_prefix_width, 2);
3381 }
3382 for row in &visible[1..] {
3383 assert!(
3384 row.starts_with(" "),
3385 "continuation rows keep the rail width indent: {row:?}"
3386 );
3387 assert!(
3388 !row.starts_with('│'),
3389 "rail appears only on the first row: {row:?}"
3390 );
3391 }
3392 for width in rendered.iter().map(|row| {
3393 row.line
3394 .spans
3395 .iter()
3396 .map(|span| span.content.as_ref().width())
3397 .sum::<usize>()
3398 }) {
3399 assert!(width <= 12, "rendered width {width} exceeds budget");
3400 }
3401 // Rows re-join into the quote content (rail and alignment stripped).
3402 let combined = visible
3403 .iter()
3404 .map(|row| row.trim_start_matches('│').trim_start())
3405 .collect::<Vec<_>>()
3406 .join(" ");
3407 assert_eq!(combined, &source[2..]);
3408 }
3409
3410 fn tagged_visible(lines: &[RenderedMarkdownLine]) -> Vec<String> {
3411 lines
3412 .iter()
3413 .map(|rendered| {
3414 rendered
3415 .line
3416 .spans
3417 .iter()
3418 .map(|span| span.content.as_ref())
3419 .collect()
3420 })
3421 .collect()
3422 }
3423
3424 #[test]
3425 fn http_links_keep_visible_text_and_out_of_band_metadata() {
3426 let source = "see https://example.com for details";
3427 let rendered = render_markdown_tagged(source, 80, Style::default());
3428 assert_eq!(tagged_visible(&rendered), vec![source]);
3429 assert!(
3430 rendered
3431 .iter()
3432 .flat_map(|line| &line.line.spans)
3433 .all(|span| { !span.content.contains('\x1b') && !span.content.contains("]8;;") }),
3434 "escape payloads must never enter visible spans"
3435 );
3436 assert_eq!(
3437 rendered[0].links,
3438 vec![osc8::LineLink {
3439 col_start: 4,
3440 col_end: 22,
3441 target: "https://example.com".to_string(),
3442 }]
3443 );
3444 }
3445
3446 #[test]
3447 fn bare_http_links_exclude_surrounding_punctuation_from_target() {
3448 let source = "see (https://example.com/path).";
3449 let rendered = render_markdown_tagged(source, 80, Style::default());
3450 assert_eq!(tagged_visible(&rendered), vec![source]);
3451 assert_eq!(rendered[0].links.len(), 1);
3452 let link = &rendered[0].links[0];
3453 assert_eq!(link.target, "https://example.com/path");
3454 assert_eq!(link.col_start, 5);
3455 assert_eq!(link.col_end, 28);
3456 }
3457
3458 #[test]
3459 fn bare_http_links_preserve_balanced_parentheses_in_target() {
3460 let url = "https://en.wikipedia.org/wiki/Function_(mathematics)";
3461 let source = format!("see {url}.");
3462 let rendered = render_markdown_tagged(&source, 100, Style::default());
3463 assert_eq!(tagged_visible(&rendered), vec![source]);
3464 assert_eq!(rendered[0].links.len(), 1);
3465 assert_eq!(rendered[0].links[0].target, url);
3466 }
3467
3468 #[test]
3469 fn wrapped_url_chunks_keep_visible_label_and_full_target() {
3470 let url = "https://raw.githubusercontent.com/Hmbown/deepseek-skills/main/index.json";
3471 let rendered = render_markdown_tagged(url, 34, Style::default());
3472 let visible = tagged_visible(&rendered);
3473 assert!(visible.len() > 1, "fixture must wrap: {visible:?}");
3474 assert_eq!(visible.concat(), url);
3475 for (line, text) in rendered.iter().zip(&visible) {
3476 assert_eq!(line.links.len(), 1, "each wrapped chunk is linked");
3477 assert_eq!(line.links[0].target, url);
3478 assert_eq!(line.links[0].col_start, 0);
3479 assert_eq!(line.links[0].col_end, text.width().saturating_sub(1));
3480 assert!(!text.contains('\x1b') && !text.contains("]8;;"));
3481 }
3482 }
3483
3484 #[test]
3485 fn named_link_shows_only_label_and_keeps_target_in_metadata() {
3486 let rendered = render_markdown_tagged(
3487 "read [the docs](https://example.com/guide) now",
3488 80,
3489 Style::default(),
3490 );
3491 assert_eq!(tagged_visible(&rendered), vec!["read the docs now"]);
3492 assert_eq!(
3493 rendered[0].links,
3494 vec![osc8::LineLink {
3495 col_start: 5,
3496 col_end: 12,
3497 target: "https://example.com/guide".to_string(),
3498 }]
3499 );
3500 }
3501
3502 #[test]
3503 fn named_links_reject_non_web_schemes_and_normalize_http_scheme() {
3504 let unsafe_link = render_markdown_tagged("[run](javascript:alert)", 80, Style::default());
3505 assert_eq!(tagged_visible(&unsafe_link), vec!["run"]);
3506 assert!(unsafe_link.iter().all(|line| line.links.is_empty()));
3507
3508 let web_link =
3509 render_markdown_tagged("[docs](HTTPS://example.com/guide)", 80, Style::default());
3510 assert_eq!(tagged_visible(&web_link), vec!["docs"]);
3511 assert_eq!(web_link[0].links[0].target, "https://example.com/guide");
3512 }
3513
3514 #[test]
3515 fn named_links_target_absolute_paths_with_the_file_scheme() {
3516 let rendered = render_markdown_tagged(
3517 "edit [main.rs](/repo/src/main.rs) now",
3518 80,
3519 Style::default(),
3520 );
3521 assert_eq!(tagged_visible(&rendered), vec!["edit main.rs now"]);
3522 assert_eq!(
3523 rendered[0].links,
3524 vec![osc8::LineLink {
3525 col_start: 5,
3526 col_end: 11,
3527 target: "file:///repo/src/main.rs".to_string(),
3528 }]
3529 );
3530
3531 let explicit =
3532 render_markdown_tagged("[main.rs](FILE:///repo/src/main.rs)", 80, Style::default());
3533 assert_eq!(explicit[0].links[0].target, "file:///repo/src/main.rs");
3534 }
3535
3536 #[test]
3537 fn named_links_reject_relative_paths_and_smuggled_control_bytes() {
3538 // Nothing here resolves a relative path against a workspace root, so a
3539 // relative destination would link to whatever the terminal's cwd is.
3540 let relative = render_markdown_tagged("[main.rs](src/main.rs)", 80, Style::default());
3541 assert_eq!(tagged_visible(&relative), vec!["main.rs"]);
3542 assert!(relative.iter().all(|line| line.links.is_empty()));
3543
3544 let hostile = render_markdown_tagged(
3545 "[log](/tmp/a\x07b\x1b]8;;https://evil.test\x1b\\c)",
3546 80,
3547 Style::default(),
3548 );
3549 assert!(
3550 hostile.iter().all(|line| line.links.is_empty()),
3551 "control bytes must not reach a link target: {hostile:?}"
3552 );
3553
3554 // A `file://host/share` destination is a remote reference, not a local
3555 // path, and must not be rewritten into one.
3556 let host = render_markdown_tagged("[share](file://evil.test/etc)", 80, Style::default());
3557 assert!(host.iter().all(|line| line.links.is_empty()));
3558 }
3559
3560 #[test]
3561 fn bare_paths_in_prose_are_never_linkified() {
3562 let rendered = render_markdown_tagged(
3563 "the fix landed in /repo/src/main.rs today",
3564 80,
3565 Style::default(),
3566 );
3567 assert!(rendered.iter().all(|line| line.links.is_empty()));
3568 }
3569
3570 #[test]
3571 fn table_separator_row_is_kept() {
3572 // Separator rows are now kept as TableSeparator blocks so the
3573 // renderer can draw horizontal rules at the correct positions.
3574 let src = "| 项目属性 | 详情 |\n|----------|------|\n| **语言** | Rust 1.88+ |\n";
3575 let parsed = parse(src);
3576 let blocks: Vec<_> = parsed.blocks.iter().collect();
3577 // Should have 2 TableRow blocks (header + data) + 1 TableSeparator
3578 let table_rows: Vec<_> = blocks
3579 .iter()
3580 .filter(|b| matches!(b, Block::TableRow(_)))
3581 .collect();
3582 assert_eq!(table_rows.len(), 2, "expected 2 table rows: {blocks:?}");
3583 let separators: Vec<_> = blocks
3584 .iter()
3585 .filter(|b| matches!(b, Block::TableSeparator))
3586 .collect();
3587 assert_eq!(
3588 separators.len(),
3589 1,
3590 "expected 1 table separator: {blocks:?}"
3591 );
3592 }
3593
3594 #[test]
3595 fn bold_markers_stripped_in_render() {
3596 let src = "这是一个 **Rust 工作区项目**,包含多个 crate。\n";
3597 let lines = render_markdown(src, 80, Style::default());
3598 let text: String = lines
3599 .iter()
3600 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3601 .collect();
3602 assert!(
3603 !text.contains("**"),
3604 "bold markers leaked into output: {text:?}"
3605 );
3606 assert!(text.contains("Rust"), "bold content missing: {text:?}");
3607 }
3608
3609 #[test]
3610 fn table_renders_with_box_drawing_borders() {
3611 let src = "| 文件 | 改动 |\n|---|---|\n| foo.rs | 重写 |\n";
3612 let lines = render_markdown(src, 60, Style::default());
3613 let text: String = lines
3614 .iter()
3615 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3616 .collect();
3617 // Column pipes still present
3618 assert!(text.contains('│'), "table pipe separator missing: {text:?}");
3619 // Separator row rendered as middle border, not raw markdown
3620 assert!(
3621 !text.contains("|---|"),
3622 "raw separator row leaked: {text:?}"
3623 );
3624 // Top and bottom borders present
3625 assert!(
3626 text.contains('\u{250C}'),
3627 "top-left corner missing: {text:?}"
3628 );
3629 assert!(
3630 text.contains('\u{2510}'),
3631 "top-right corner missing: {text:?}"
3632 );
3633 assert!(
3634 text.contains('\u{2514}'),
3635 "bottom-left corner missing: {text:?}"
3636 );
3637 assert!(
3638 text.contains('\u{2518}'),
3639 "bottom-right corner missing: {text:?}"
3640 );
3641 // Middle separator present (at the |---|---| position)
3642 assert!(
3643 text.contains('\u{251C}'),
3644 "middle-left junction missing: {text:?}"
3645 );
3646 assert!(
3647 text.contains('\u{2524}'),
3648 "middle-right junction missing: {text:?}"
3649 );
3650 }
3651
3652 #[test]
3653 fn table_pipes_inside_inline_code_stay_in_the_cell() {
3654 let src = "| Check | Result |\n\
3655 |---|---|\n\
3656 | `strings ~/.cargo/bin/codewhale-tui | grep -c \"legacy marker\"` | 0 matches |\n";
3657 let parsed = parse(src);
3658
3659 let rows: Vec<&Vec<String>> = parsed
3660 .blocks
3661 .iter()
3662 .filter_map(|block| match block {
3663 Block::TableRow(cells) => Some(cells),
3664 _ => None,
3665 })
3666 .collect();
3667
3668 assert_eq!(rows.len(), 2, "expected header + data row: {rows:?}");
3669 assert_eq!(
3670 rows[1],
3671 &vec![
3672 "`strings ~/.cargo/bin/codewhale-tui | grep -c \"legacy marker\"`".to_string(),
3673 "0 matches".to_string(),
3674 ]
3675 );
3676
3677 let rendered_lines = visible_lines(&render_markdown(src, 200, Style::default()));
3678 let rendered = rendered_lines.join("\n");
3679 assert!(
3680 rendered.contains("grep -c"),
3681 "inline-code command was lost: {rendered}"
3682 );
3683 let data_line = rendered_lines
3684 .iter()
3685 .find(|line| line.contains("strings ~/.cargo/bin/codewhale-tui"))
3686 .expect("data row should render");
3687 assert_eq!(
3688 data_line.matches('│').count(),
3689 3,
3690 "two-column table row should have left, middle, and right separators: {data_line:?}"
3691 );
3692 }
3693
3694 /// Cells longer than the per-column width must word-wrap to multiple
3695 /// lines instead of getting truncated with `…`. Truncation silently
3696 /// drops content the user can never see — particularly bad in narrow
3697 /// Windows terminals or with verbose English/Chinese instructional
3698 /// tables (the common LLM-output case).
3699 #[test]
3700 fn table_cell_wider_than_column_wraps_instead_of_truncating() {
3701 let src = "| Feature | How to verify |\n\
3702 |---|---|\n\
3703 | Workspace-local commands | Drop a .deepseek/commands/foo.md in any project, run deepseek from there, type /foo — should dispatch |\n";
3704 let lines = render_markdown(src, 80, Style::default());
3705 let combined: String = lines
3706 .iter()
3707 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3708 .collect();
3709
3710 assert!(
3711 !combined.contains('…'),
3712 "table cell was truncated with `…` instead of wrapping; got: {combined:?}"
3713 );
3714 assert!(
3715 combined.contains("type /foo"),
3716 "tail of long cell was lost; got: {combined:?}"
3717 );
3718 assert!(
3719 combined.contains("Workspace-local commands"),
3720 "short cell content lost; got: {combined:?}"
3721 );
3722 }
3723
3724 /// Wrapped table rows must keep column separators on every visual
3725 /// line so the columns remain visually aligned across all wrapped
3726 /// segments. A wrapped row's continuation lines should still show
3727 /// the `│` separator pipes at the same column positions.
3728 #[test]
3729 fn wrapped_table_row_preserves_column_separators() {
3730 let src = "| A | B |\n\
3731 |---|---|\n\
3732 | short | this is a very very long second cell that absolutely must wrap to a new visual line because it cannot fit in the column allocated to it at this terminal width |\n";
3733 let lines = render_markdown(src, 60, Style::default());
3734 let rendered: Vec<String> = lines
3735 .iter()
3736 .map(|l| {
3737 l.spans
3738 .iter()
3739 .map(|s| s.content.as_ref())
3740 .collect::<String>()
3741 })
3742 .collect();
3743
3744 // Every line in the rendered table — including wrapped continuation
3745 // lines — must show the pipe column separator. We identify table
3746 // body lines as ones that start with the row separator `│`.
3747 let body_lines: Vec<&String> = rendered.iter().filter(|s| s.starts_with('│')).collect();
3748
3749 assert!(
3750 body_lines.len() >= 3,
3751 "expected at least header + multi-line data row (3+ body lines), got {}: {:?}",
3752 body_lines.len(),
3753 body_lines
3754 );
3755
3756 for line in &body_lines {
3757 assert!(
3758 line.matches('│').count() >= 3,
3759 "every wrapped table line should have N+1 column separators \
3760 for N columns; got fewer in: {line:?}"
3761 );
3762 }
3763
3764 // All of the long cell's content must appear across the wrapped lines.
3765 let combined: String = rendered.join("\n");
3766 for fragment in ["this is a very very long", "must wrap", "terminal width"] {
3767 assert!(
3768 combined.contains(fragment),
3769 "fragment {fragment:?} missing from wrapped output:\n{combined}"
3770 );
3771 }
3772 }
3773
3774 // ─── Paragraph wrap regression suite (#1344, #1351) ────────────────────
3775 //
3776 // The bug: paragraph wrap (render_line_with_links) and code-block wrap
3777 // (wrap_text) are word-based. A single word wider than the available
3778 // width was placed alone on a line and silently overflowed the right
3779 // edge of the transcript. Long URLs / paths / hashes / no-whitespace
3780 // CJK runs all hit this. The fix hard-breaks overlong words at
3781 // grapheme boundaries; these tests pin that across widths 40/60/80/120.
3782
3783 fn rendered_widths(rendered: &[Line<'static>]) -> Vec<usize> {
3784 rendered
3785 .iter()
3786 .map(|l| {
3787 l.spans
3788 .iter()
3789 .map(|s| s.content.as_ref().width())
3790 .sum::<usize>()
3791 })
3792 .collect()
3793 }
3794
3795 fn render_paragraph_for_test(text: &str, width: usize) -> Vec<Line<'static>> {
3796 render_line_with_links(text, width, Style::default(), Style::default())
3797 }
3798
3799 #[test]
3800 fn paragraph_wrap_breaks_overlong_word_at_width_40() {
3801 // 200-char no-whitespace token must not exceed the 40-col window.
3802 let long = "a".repeat(200);
3803 let rendered = render_paragraph_for_test(&long, 40);
3804 for w in rendered_widths(&rendered) {
3805 assert!(w <= 40, "rendered width {w} exceeds 40-col window");
3806 }
3807 // And the full content must still be present across the wrapped lines.
3808 let combined: String = rendered
3809 .iter()
3810 .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
3811 .collect();
3812 assert_eq!(combined.matches('a').count(), 200);
3813 }
3814
3815 #[test]
3816 fn paragraph_wrap_breaks_no_whitespace_cjk_at_width_40() {
3817 // #963: long CJK runs without whitespace must wrap by display width
3818 // instead of overflowing or truncating. Each Han character is 2 cols.
3819 let long = "界".repeat(300);
3820 let rendered = render_paragraph_for_test(&long, 40);
3821 for w in rendered_widths(&rendered) {
3822 assert!(w <= 40, "rendered width {w} exceeds 40-col window");
3823 }
3824 let combined: String = rendered
3825 .iter()
3826 .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
3827 .collect();
3828 assert_eq!(combined.chars().filter(|&ch| ch == '界').count(), 300);
3829 assert!(
3830 rendered.len() >= 15,
3831 "300 double-width chars should wrap into many rows, got {}",
3832 rendered.len()
3833 );
3834 }
3835
3836 #[test]
3837 fn paragraph_wrap_breaks_overlong_word_at_widths_60_80_120() {
3838 let long = format!("https://example.com/{}", "p".repeat(180));
3839 for &width in &[60usize, 80, 120] {
3840 let rendered = render_paragraph_for_test(&long, width);
3841 for w in rendered_widths(&rendered) {
3842 assert!(
3843 w <= width,
3844 "width={width}: rendered line width {w} exceeds budget"
3845 );
3846 }
3847 assert!(rendered.len() >= 2, "width={width}: expected wrap");
3848 }
3849 }
3850
3851 #[test]
3852 fn paragraph_wrap_keeps_short_words_unbroken() {
3853 // Regression guard: short words must still be broken at whitespace,
3854 // not mid-word. Width 40, only short words, expect zero mid-word
3855 // breaks (each line reads as natural English).
3856 let text = "the quick brown fox jumps over the lazy dog and then it stops moving";
3857 let rendered = render_paragraph_for_test(text, 40);
3858 for line in &rendered {
3859 let s: String = line.spans.iter().map(|s| s.content.to_string()).collect();
3860 // Heuristic: trimmed line should not start with a partial word
3861 // (i.e. should start with a real English start) — every line in
3862 // this fixture starts with a word in our short list.
3863 let first = s.split_whitespace().next().unwrap_or("");
3864 assert!(
3865 [
3866 "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then",
3867 "it", "stops", "moving"
3868 ]
3869 .contains(&first),
3870 "line {s:?} appears to start with a partial word"
3871 );
3872 }
3873 }
3874
3875 #[test]
3876 fn paragraph_wrap_mixed_short_and_overlong_word() {
3877 // The overlong word must wrap; the trailing short words must pack
3878 // onto subsequent lines. The combined content is preserved.
3879 let long = "x".repeat(150);
3880 let text = format!("intro {long} tail words go here");
3881 let rendered = render_paragraph_for_test(&text, 80);
3882 for w in rendered_widths(&rendered) {
3883 assert!(w <= 80, "rendered width {w} exceeds 80-col window");
3884 }
3885 let combined: String = rendered
3886 .iter()
3887 .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
3888 .collect();
3889 for fragment in ["intro", "tail", "words", "go", "here"] {
3890 assert!(
3891 combined.contains(fragment),
3892 "fragment {fragment:?} missing from wrapped output:\n{combined}"
3893 );
3894 }
3895 assert_eq!(combined.matches('x').count(), 150);
3896 }
3897
3898 #[test]
3899 fn wrap_text_breaks_overlong_word_for_code_blocks() {
3900 // The standalone code-block wrap (wrap_text) had the same overflow
3901 // bug; pin the fix at widths 40 and 80.
3902 for &width in &[40usize, 80] {
3903 let long = "z".repeat(200);
3904 let lines = wrap_text(&long, width);
3905 for line in &lines {
3906 assert!(
3907 line.width() <= width,
3908 "wrap_text line {line:?} exceeds {width}"
3909 );
3910 }
3911 let combined: String = lines.join("");
3912 assert_eq!(combined.matches('z').count(), 200);
3913 }
3914 }
3915
3916 #[test]
3917 fn wrap_cell_text_already_handled_long_words_remains_correct() {
3918 // Regression guard for the v0.8.25 table-cell fix. After consolidating
3919 // the char-break helper, wrap_cell_text must continue to handle
3920 // overlong cells. Pin the property: every wrapped segment fits
3921 // within the column width, and content is preserved.
3922 let long = "y".repeat(120);
3923 let segments = wrap_cell_text(&long, 30);
3924 for seg in &segments {
3925 assert!(seg.width() <= 30, "segment {seg:?} exceeds col 30");
3926 }
3927 let combined: String = segments.join("");
3928 assert_eq!(combined.matches('y').count(), 120);
3929 }
3930
3931 #[test]
3932 fn paragraph_wrap_handles_zero_width_gracefully() {
3933 // Width 0 should not panic or hang; it returns the input as-is or
3934 // empty, but never produces a line wider than 0 (when 0 means "no
3935 // budget at all"). This pins the early-return path against future
3936 // regressions.
3937 let rendered = render_paragraph_for_test("hello world", 0);
3938 // Any output is acceptable (the path is degenerate); assert no panic.
3939 let _ = rendered;
3940 }
3941
3942 fn rendered_text(rendered: &[Line<'static>]) -> String {
3943 rendered
3944 .iter()
3945 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3946 .collect()
3947 }
3948
3949 fn assert_rendered_widths_fit(rendered: &[Line<'static>], width: usize, label: &str) {
3950 for line_width in rendered_widths(rendered) {
3951 assert!(
3952 line_width <= width,
3953 "{label} width={width}: rendered line width {line_width} exceeds budget"
3954 );
3955 }
3956 }
3957
3958 // ── Unicode / CJK / emoji / combining-char width QA (#3488) ────────────
3959
3960 #[test]
3961 fn paragraph_wrap_keeps_unicode_runs_within_qa_widths() {
3962 let cases = [
3963 ("cjk", "界".repeat(300)),
3964 ("emoji", "😀".repeat(200)),
3965 ("mixed-cjk-emoji", "界😀世🚀".repeat(90)),
3966 ];
3967
3968 for (label, text) in cases {
3969 for &width in &[80usize, 100, 120] {
3970 let rendered = render_paragraph_for_test(&text, width);
3971 assert_rendered_widths_fit(&rendered, width, label);
3972 assert_eq!(
3973 rendered_text(&rendered),
3974 text,
3975 "{label} width={width}: content changed while wrapping"
3976 );
3977
3978 let min_lines = text.width().div_ceil(width);
3979 assert!(
3980 rendered.len() >= min_lines,
3981 "{label} width={width}: expected at least {min_lines} lines, got {}",
3982 rendered.len()
3983 );
3984 }
3985 }
3986 }
3987
3988 #[test]
3989 fn paragraph_wrap_preserves_mixed_unicode_and_ascii_fragments() {
3990 let cjk = "这是一个测试字符串".repeat(10); // 80 Han chars = 160 cols
3991 let emoji = "🚀".repeat(12);
3992 let text = format!("Note: {cjk} done {emoji}");
3993
3994 for &width in &[80usize, 100, 120] {
3995 let rendered = render_paragraph_for_test(&text, width);
3996 assert_rendered_widths_fit(&rendered, width, "mixed unicode/ascii");
3997
3998 let visible = visible_lines(&rendered).join("\n");
3999 for fragment in ["Note:", "测试", "done"] {
4000 assert!(
4001 visible.contains(fragment),
4002 "width={width}: fragment {fragment:?} missing from output:\n{visible}"
4003 );
4004 }
4005 assert_eq!(
4006 visible.matches('🚀').count(),
4007 12,
4008 "width={width}: emoji content lost"
4009 );
4010 }
4011 }
4012
4013 #[test]
4014 fn lower_level_wrap_text_keeps_unicode_runs_within_qa_widths() {
4015 let cases = [
4016 ("cjk", "中".repeat(140)),
4017 ("emoji", "😀".repeat(110)),
4018 ("combining", "e\u{301}".repeat(140)),
4019 ];
4020
4021 for (label, input) in cases {
4022 for &width in &[80usize, 100, 120] {
4023 let lines = wrap_text(&input, width);
4024 for line in &lines {
4025 assert!(
4026 line.width() <= width,
4027 "{label} width={width}: wrap_text line {line:?} exceeds budget"
4028 );
4029 }
4030 let combined: String = lines.join("");
4031 assert_eq!(
4032 combined, input,
4033 "{label} width={width}: wrap_text changed content"
4034 );
4035 }
4036 }
4037 }
4038
4039 #[test]
4040 fn table_render_keeps_cjk_cells_within_qa_widths() {
4041 let cjk = "界".repeat(80);
4042 let src = format!("| Name | Value |\n|---|---|\n| CJK | {cjk} |\n");
4043
4044 for &width in &[80usize, 100, 120] {
4045 let rendered = render_markdown(&src, width as u16, Style::default());
4046 assert_rendered_widths_fit(&rendered, width, "table cjk");
4047 assert_eq!(
4048 rendered_text(&rendered).matches('界').count(),
4049 80,
4050 "width={width}: CJK table cell content lost"
4051 );
4052 }
4053 }
4054
4055 #[test]
4056 fn paragraph_wrap_keeps_cjk_transcript_within_narrow_widths() {
4057 // The seed cases cover 80/100/120; narrow terminals (resize / small
4058 // panes) are the other half of #3488's terminal-width lane. A CJK
4059 // transcript paragraph must still wrap inside tiny windows without
4060 // overflowing the border or dropping content.
4061 let text = "实时输出结果显示正常".repeat(6); // 60 Han glyphs, 120 cols
4062 for &width in &[20usize, 40] {
4063 let rendered = render_paragraph_for_test(&text, width);
4064 assert_rendered_widths_fit(&rendered, width, "narrow cjk transcript");
4065 assert_eq!(
4066 rendered_text(&rendered),
4067 text,
4068 "width={width}: CJK transcript content changed while wrapping"
4069 );
4070 }
4071 }
4072
4073 // A deliberate measurement probe, not an assertion: run it with
4074 // `--nocapture` to read the per-append cost. Printing is the whole point,
4075 // so the module-wide stdout ban is lifted here the same way
4076 // `core/engine/tests.rs` lifts it for its probes.
4077 #[allow(clippy::print_stdout)]
4078 #[test]
4079 fn probe_incremental_stream_cost() {
4080 // Faithful to one streaming message: the document grows a word at a
4081 // time and the render path re-parses and re-renders the whole visible
4082 // text on every tick. Reports the cost per 250 appends so growth with
4083 // message size is visible.
4084 use std::time::Instant;
4085 let words: Vec<&str> =
4086 "the quick brown fox jumps over a lazy dog and then keeps going for a while"
4087 .split(' ')
4088 .collect();
4089 let mut doc = String::new();
4090 let width = 100u16;
4091 let mut window_start = Instant::now();
4092 for i in 0..2000usize {
4093 doc.push_str(words[i % words.len()]);
4094 doc.push(' ');
4095 let parsed = parse(&doc);
4096 let lines = render_parsed(&parsed, width, Style::default());
4097 std::hint::black_box(&lines);
4098 if (i + 1) % 250 == 0 {
4099 let elapsed = window_start.elapsed();
4100 println!(
4101 "PROBE bytes={} per_append={:?} total={:?}",
4102 doc.len(),
4103 elapsed / 250,
4104 elapsed
4105 );
4106 window_start = Instant::now();
4107 }
4108 }
4109 }
4110 }
4111
4111 lines RUST