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