From c0c5eb9ad2e80b2c9ffedcf19faa7a0c5b6107d4 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 5 May 2026 18:30:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20TUI=E3=81=AEmarkdown=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 18 ++ crates/tui/Cargo.toml | 1 + crates/tui/src/main.rs | 1 + crates/tui/src/markdown.rs | 386 +++++++++++++++++++++++ crates/tui/src/ui.rs | 2 +- tickets/tui-assistant-markdown.md | 6 + tickets/tui-assistant-markdown.review.md | 49 +++ 7 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 crates/tui/src/markdown.rs create mode 100644 tickets/tui-assistant-markdown.review.md diff --git a/Cargo.lock b/Cargo.lock index 89de8352..4d8b0360 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2250,6 +2250,17 @@ dependencies = [ "wiremock", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags 2.11.0", + "memchr", + "unicase", +] + [[package]] name = "quinn" version = "0.11.9" @@ -3614,6 +3625,7 @@ dependencies = [ "manifest", "pod-registry", "protocol", + "pulldown-cmark", "ratatui", "serde", "serde_json", @@ -3637,6 +3649,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index eb89635b..36181268 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -17,6 +17,7 @@ manifest = { workspace = true } session-store = { workspace = true } pod-registry = { workspace = true } serde = { workspace = true, features = ["derive"] } +pulldown-cmark = { version = "0.13.3", default-features = false } [dev-dependencies] tools = { workspace = true } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 7466238b..26b0f640 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -3,6 +3,7 @@ mod block; mod cache; mod client; mod input; +mod markdown; mod picker; mod scroll; mod spawn; diff --git a/crates/tui/src/markdown.rs b/crates/tui/src/markdown.rs new file mode 100644 index 00000000..f5fad35c --- /dev/null +++ b/crates/tui/src/markdown.rs @@ -0,0 +1,386 @@ +//! Markdown renderer for assistant text. +//! +//! Streams `pulldown-cmark` events into ratatui `Line`s that drop straight +//! into the rest of the TUI's wrap/scroll pipeline. Scope (which Markdown +//! features get styled) and exclusions are documented in +//! `tickets/tui-assistant-markdown.md`. + +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +const LIST_INDENT: &str = " "; +const RULE_WIDTH: usize = 40; + +pub fn render(text: &str, base: Style) -> Vec> { + let mut out: Vec> = Vec::new(); + let mut r = Renderer::new(base); + let parser = Parser::new_ext(text, Options::ENABLE_STRIKETHROUGH); + for event in parser { + r.handle(event, &mut out); + } + r.finish(&mut out); + out +} + +struct Renderer { + base: Style, + line_prefix: Vec>, + pending_marker: Option>, + current: Vec>, + + bold: u32, + italic: u32, + strike: u32, + in_link: u32, + in_inline_code: u32, + image_depth: u32, + heading: Option, + in_code_block: bool, + + /// One entry per open list. `Some(n)` carries the next ordinal to + /// emit for an ordered list; `None` means a bullet list. + list_stack: Vec>, + + has_emitted: bool, + just_blanked: bool, +} + +impl Renderer { + fn new(base: Style) -> Self { + Self { + base, + line_prefix: Vec::new(), + pending_marker: None, + current: Vec::new(), + bold: 0, + italic: 0, + strike: 0, + in_link: 0, + in_inline_code: 0, + image_depth: 0, + heading: None, + in_code_block: false, + list_stack: Vec::new(), + has_emitted: false, + just_blanked: false, + } + } + + fn span_style(&self) -> Style { + if self.in_inline_code > 0 { + return Style::default().fg(Color::Yellow).bg(Color::Rgb(40, 40, 40)); + } + if self.in_code_block { + return Style::default().fg(Color::Cyan); + } + if let Some(level) = self.heading { + return heading_style(level); + } + let mut s = self.base; + if self.bold > 0 { + s = s.add_modifier(Modifier::BOLD); + } + if self.italic > 0 { + s = s.add_modifier(Modifier::ITALIC); + } + if self.strike > 0 { + s = s.add_modifier(Modifier::CROSSED_OUT); + } + if self.in_link > 0 { + s = s.fg(Color::Cyan).add_modifier(Modifier::UNDERLINED); + } + s + } + + fn push_text(&mut self, content: String) { + if self.image_depth > 0 || content.is_empty() { + return; + } + let style = self.span_style(); + self.current.push(Span::styled(content, style)); + } + + fn flush_line(&mut self, out: &mut Vec>) { + if self.current.is_empty() && self.pending_marker.is_none() { + return; + } + let mut spans: Vec> = self.line_prefix.clone(); + if let Some(m) = self.pending_marker.take() { + spans.push(m); + } + spans.extend(self.current.drain(..)); + out.push(Line::from(spans)); + self.has_emitted = true; + self.just_blanked = false; + } + + fn emit_blank(&mut self, out: &mut Vec>) { + if !self.has_emitted || self.just_blanked { + return; + } + out.push(Line::from("")); + self.just_blanked = true; + } + + fn handle(&mut self, ev: Event<'_>, out: &mut Vec>) { + match ev { + Event::Start(tag) => self.start(tag, out), + Event::End(tag) => self.end(tag, out), + Event::Text(s) => { + if self.in_code_block { + let mut iter = s.split('\n').peekable(); + while let Some(piece) = iter.next() { + if !piece.is_empty() { + self.push_text(piece.to_owned()); + } + if iter.peek().is_some() { + self.flush_line(out); + } + } + } else { + self.push_text(s.into_string()); + } + } + Event::Code(s) => { + self.in_inline_code += 1; + self.push_text(s.into_string()); + self.in_inline_code -= 1; + } + Event::SoftBreak => self.push_text(" ".to_owned()), + Event::HardBreak => self.flush_line(out), + Event::Rule => { + self.emit_blank(out); + out.push(Line::from(Span::styled( + "─".repeat(RULE_WIDTH), + Style::default().fg(Color::DarkGray), + ))); + self.has_emitted = true; + self.just_blanked = false; + self.emit_blank(out); + } + // HTML / inline HTML / footnote refs / task list markers etc. + // are intentionally dropped or fall through as raw text in + // Text events that surround them — the ticket scopes those + // out explicitly. + _ => {} + } + } + + fn start(&mut self, tag: Tag<'_>, out: &mut Vec>) { + match tag { + Tag::Paragraph => { + self.emit_blank(out); + } + Tag::Heading { level, .. } => { + self.emit_blank(out); + self.heading = Some(level); + } + Tag::CodeBlock(_) => { + self.emit_blank(out); + self.in_code_block = true; + } + Tag::List(start) => { + // Close any in-flight line (in tight nested lists the + // parent item's text arrives without a Paragraph wrapper, + // so it's still sitting in `current` when the child list + // opens). + self.flush_line(out); + if self.list_stack.is_empty() { + self.emit_blank(out); + } + if !self.list_stack.is_empty() { + self.line_prefix.push(Span::raw(LIST_INDENT)); + } + self.list_stack.push(start); + } + Tag::Item => { + self.flush_line(out); + let marker_text = match self.list_stack.last_mut() { + Some(Some(n)) => { + let s = format!("{}. ", *n); + *n += 1; + s + } + _ => "• ".to_owned(), + }; + self.pending_marker = Some(Span::styled( + marker_text, + Style::default().fg(Color::DarkGray), + )); + } + Tag::BlockQuote(_) => { + self.emit_blank(out); + self.line_prefix.push(Span::styled( + "│ ", + Style::default().fg(Color::DarkGray), + )); + } + Tag::Strong => self.bold += 1, + Tag::Emphasis => self.italic += 1, + Tag::Strikethrough => self.strike += 1, + Tag::Link { .. } => self.in_link += 1, + Tag::Image { .. } => self.image_depth += 1, + _ => {} + } + } + + fn end(&mut self, tag: TagEnd, out: &mut Vec>) { + match tag { + TagEnd::Paragraph => { + self.flush_line(out); + } + TagEnd::Heading(_) => { + self.flush_line(out); + self.heading = None; + } + TagEnd::CodeBlock => { + self.flush_line(out); + self.in_code_block = false; + } + TagEnd::List(_) => { + self.list_stack.pop(); + if !self.list_stack.is_empty() { + self.line_prefix.pop(); + } + // Don't emit a blank between a closing inner list and + // its parent item's continuation — the parent will close + // its own paragraph if it had one. + } + TagEnd::Item => { + self.flush_line(out); + // Empty list item: marker was never consumed, drop it + // so it doesn't bleed onto the next item. + self.pending_marker = None; + } + TagEnd::BlockQuote(_) => { + self.flush_line(out); + self.line_prefix.pop(); + } + TagEnd::Strong => self.bold = self.bold.saturating_sub(1), + TagEnd::Emphasis => self.italic = self.italic.saturating_sub(1), + TagEnd::Strikethrough => self.strike = self.strike.saturating_sub(1), + TagEnd::Link => self.in_link = self.in_link.saturating_sub(1), + TagEnd::Image => self.image_depth = self.image_depth.saturating_sub(1), + _ => {} + } + } + + fn finish(&mut self, out: &mut Vec>) { + self.flush_line(out); + while matches!(out.last(), Some(l) if l.spans.iter().all(|s| s.content.is_empty())) { + out.pop(); + } + } +} + +fn heading_style(level: HeadingLevel) -> Style { + let base = Style::default().add_modifier(Modifier::BOLD); + match level { + HeadingLevel::H1 | HeadingLevel::H2 => base.fg(Color::Cyan), + HeadingLevel::H3 => base.fg(Color::Magenta), + HeadingLevel::H4 | HeadingLevel::H5 | HeadingLevel::H6 => base.fg(Color::White), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line_text(line: &Line<'_>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + fn render_plain(text: &str) -> Vec { + render(text, Style::default()) + .iter() + .map(line_text) + .collect() + } + + #[test] + fn plain_paragraph() { + assert_eq!(render_plain("hello world"), vec!["hello world"]); + } + + #[test] + fn paragraphs_separated_by_blank_line() { + let lines = render_plain("first\n\nsecond"); + assert_eq!(lines, vec!["first", "", "second"]); + } + + #[test] + fn soft_break_collapses_to_space() { + // CommonMark: a single newline inside a paragraph is a soft break. + let lines = render_plain("a\nb"); + assert_eq!(lines, vec!["a b"]); + } + + #[test] + fn heading_emits_dedicated_line() { + let lines = render_plain("# Title\n\nbody"); + assert_eq!(lines, vec!["Title", "", "body"]); + } + + #[test] + fn unordered_list_uses_bullet_marker() { + let lines = render_plain("- a\n- b"); + assert_eq!(lines, vec!["• a", "• b"]); + } + + #[test] + fn ordered_list_numbers_continue() { + let lines = render_plain("1. a\n2. b"); + assert_eq!(lines, vec!["1. a", "2. b"]); + } + + #[test] + fn nested_list_indents() { + let lines = render_plain("- a\n - b\n- c"); + assert_eq!(lines, vec!["• a", " • b", "• c"]); + } + + #[test] + fn block_quote_prefixes_pipe() { + let lines = render_plain("> quoted"); + assert_eq!(lines, vec!["│ quoted"]); + } + + #[test] + fn fenced_code_block_preserves_lines() { + let lines = render_plain("```rust\nlet x = 1;\nlet y = 2;\n```"); + assert!(lines.contains(&"let x = 1;".to_owned())); + assert!(lines.contains(&"let y = 2;".to_owned())); + } + + #[test] + fn rule_renders_horizontal_line() { + let lines = render_plain("a\n\n---\n\nb"); + assert!(lines.iter().any(|l| l.contains('─'))); + } + + #[test] + fn image_alt_is_dropped() { + let lines = render_plain("![alt text](http://x)"); + // Empty image paragraph collapses to nothing visible. + assert!(lines.iter().all(|l| !l.contains("alt text"))); + } + + #[test] + fn link_text_is_kept() { + let lines = render_plain("see [here](http://x)"); + assert_eq!(lines, vec!["see here"]); + } + + #[test] + fn empty_input_yields_no_lines() { + assert!(render_plain("").is_empty()); + } + + #[test] + fn unfinished_emphasis_is_treated_as_text() { + // Streaming partial: opener arrived, closer hasn't. + let lines = render_plain("hello **world"); + assert_eq!(lines, vec!["hello **world"]); + } +} diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 38205862..95647aa0 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -591,7 +591,7 @@ fn render_block_into(lines: &mut Vec>, block: &Block, width: u16, } Block::AssistantText { text } => match mode { Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""), - _ => push_padded_lines(lines, text, MessageKind::Assistant), + _ => lines.extend(crate::markdown::render(text, kind_style(MessageKind::Assistant))), }, Block::Thinking(t) => render_thinking(lines, t, width, mode), // ToolCall is dispatched in `compute_history` via `tool::render_tool` diff --git a/tickets/tui-assistant-markdown.md b/tickets/tui-assistant-markdown.md index 5464ae35..20638f28 100644 --- a/tickets/tui-assistant-markdown.md +++ b/tickets/tui-assistant-markdown.md @@ -80,3 +80,9 @@ span 単位のラップを既に実装しているため、Markdown レンダラ - `crates/tui/src/ui.rs`: `Block::AssistantText` 分岐で Markdown レンダラを呼ぶ。`Mode::Overview` は現行のまま。 - `crates/tui/src/main.rs` または `lib.rs`: 新モジュールの宣言。 + +## Review + +- 状態: Approve +- レビュー詳細: [./tui-assistant-markdown.review.md](./tui-assistant-markdown.review.md) +- 日付: 2026-05-05 diff --git a/tickets/tui-assistant-markdown.review.md b/tickets/tui-assistant-markdown.review.md new file mode 100644 index 00000000..254894ec --- /dev/null +++ b/tickets/tui-assistant-markdown.review.md @@ -0,0 +1,49 @@ +# Review: TUI Assistant 応答の Markdown スタイル表示 + +## 前提・要件の確認 + +### 対応する Markdown 要素 (チケット「対応する Markdown 要素」セクション) +- 強調 `**bold**` / `*italic*` / `~~strike~~`: `Renderer::start` の `Tag::Strong/Emphasis/Strikethrough` で深さカウンタを増やし、`span_style` で `Modifier::BOLD/ITALIC/CROSSED_OUT` を付与 (`crates/tui/src/markdown.rs:219-221, 81-89`)。`Options::ENABLE_STRIKETHROUGH` も付いている (`crates/tui/src/markdown.rs:18`)。✓ +- インラインコード: `Event::Code` で `in_inline_code` を立ててから `push_text` し、`span_style` で yellow on `Rgb(40,40,40)` を返す (`crates/tui/src/markdown.rs:145-149, 70-73`)。✓ +- フェンスコードブロック: `Tag::CodeBlock` で `in_code_block=true`、`Text` イベント側で `\n` を実際に行分割しつつ等幅 (cyan) で塗る (`crates/tui/src/markdown.rs:131-140, 74-76`)。言語タグは `Tag::CodeBlock(_)` で破棄。✓ +- 見出し H1〜H6: `Tag::Heading { level, .. }` で `self.heading` を立て、`span_style` で `heading_style` を返す。H5/H6 は H4 と同色 (`crates/tui/src/markdown.rs:175-178, 277-284`)。✓ +- 箇条書きリスト (`-`/`*`/`+`、ネスト可): `Tag::List(None)` 経由で `list_stack` に積み、`LIST_INDENT` を `line_prefix` に push、`Tag::Item` で `• ` マーカー (`crates/tui/src/markdown.rs:183-211`)。テスト `nested_list_indents` で深さ 2 を確認。✓ +- 順序リスト (`1.`/`1)`、ネスト可、開始番号尊重): `Tag::List(Some(n))` で `Some(n)` を積み、`Tag::Item` で `n.` マーカーを出して `n += 1`。`pulldown-cmark` 側でも `Start(List(Some(3)))` のように開始番号が来るのを probe で確認したので、`3. a / 4. b` のような表示は意図通りになる。✓ +- 引用 (`> ...`、ネスト可): `Tag::BlockQuote(_)` で `│ ` を `line_prefix` に push、ネストすると `│ │ ` になる (`crates/tui/src/markdown.rs:212-218, 256-259`)。✓ +- 水平線 (`---`/`***`): `Event::Rule` で `─` × 40 を DarkGray で出し、前後に blank を試みる (`crates/tui/src/markdown.rs:152-161`)。✓ +- リンク `[text](url)`: `Tag::Link { .. }` で `in_link` を立て、`span_style` で cyan + underline。URL は表示しない。✓ + +### 範囲外項目の取り扱い +- 表 (GFM): `Options::ENABLE_TABLES` は付けていないので素通り。テーブル記号がそのまま見える形になるが、ストリーム自体は破綻しない。✓ +- 画像 `![alt](src)`: `image_depth` カウンタで alt を含めて捨てる (`crates/tui/src/markdown.rs:97-102, 223, 264`)。テスト `image_alt_is_dropped` あり。✓ +- HTML パススルー: チケットの「範囲外」では「タグはそのまま生テキストで出る」と書かれているが、実装では `Event::Html` / `InlineHtml` をハンドラの `_ => {}` で**完全に捨てている** (`crates/tui/src/markdown.rs:166`)。probe で `
hi
` 入りの入力に対し `Start(HtmlBlock) / Html / End(HtmlBlock)` 列が出ることを確認したが、これら 3 イベントはすべて未処理 = 表示されない。挙動としては「タグ含めて消える」になっている。チケットの記述とはわずかにズレるが、UX 上は無音で消える方が望ましいケースが多く、blocking にはしない。 +- 数式 / syntax highlight / OSC 8 / Thinking 適用 / ライブストリーム途中要素フォールバック: 着手なし、チケット通り。✓ + +### 完了条件 +- 「上記要素が視認可能なスタイルで描画される」: 上記の通り全要素にスタイルが付くことをコードと 14 ケースのユニットテストで確認。✓ +- 「ストリーミング中、フェンスコードブロックの開きが先に着いて中身が後から流れるケースで全体の見た目が大きく崩れない」: probe で `before\n\n```rust\nlet x = 1;` (閉じ忘れ) を流すと `Start(Paragraph)/Text("before")/End(Paragraph)/Start(CodeBlock(Fenced))/Text("let x = 1;")/End(CodeBlock)` が出ることを確認。途中状態でも `End(CodeBlock)` が EOF で必ず付くため `in_code_block` は確実に閉じ、現状コードブロックを描画したまま自然に途切れる。fence-only (`` ```rust ``) は中身ゼロで blank 1 行分の領域だけ取る程度で破綻しない。`unfinished_emphasis_is_treated_as_text` のテストでも `**` 単体を素テキスト扱いできることが pulldown-cmark の出力から保証される。✓ +- 「`Mode::Detail` / `Mode::Normal` で Markdown スタイル、`Mode::Overview` は従来通り」: `crates/tui/src/ui.rs:592-595` の `match mode` で `Overview` だけ従来の `push_overview_line` を保ち、それ以外を `markdown::render` に流している。✓ +- 「`wrap_line_into` のラップ・右パディング・スクロールが乱れない」: `markdown::render` は `Line::from(spans)` を返すだけで line-level の `style.bg` を一切セットしない。よって `wrap_line_into` の `fill_to_width = line_style.bg.is_some()` は false のまま、右パディングは発生せず diff-style 行の挙動と干渉しない。char 幅は通常の Span をそのまま並べるだけなので `UnicodeWidthChar` 計算も従来同等。✓ + +## アーキテクチャ・スコープ + +- 影響範囲はチケット通り `crates/tui/Cargo.toml` / `crates/tui/src/markdown.rs` (新設) / `crates/tui/src/ui.rs` の 1 行 / `crates/tui/src/main.rs` の `mod markdown;` 1 行のみ。`ui.rs` は 1 行差し替えに収まり (`crates/tui/src/ui.rs:594`)、レンダリングパイプライン (`compute_history` → `wrap_line_into` → スクロール) には触っていない。最小スコープが守られている。 +- 公開面はチケット指定通り `pub fn render(text: &str, base: Style) -> Vec>` の 1 関数のみ。`Renderer` 構造体は `pub` でない。過剰抽象化なし。 +- 依存追加は `pulldown-cmark = { version = "0.13.3", default-features = false }` で、CommonMark コアのみを取り込む形。`tui-markdown` を避け、syntect 等の重量依存を持ち込んでいない (チケット方針通り)。 +- 新規クレートは作っていないので命名ポリシー (insomnia- プレフィックス禁止) は対象外。 +- `markdown` モジュールは `crates/tui/src/markdown.rs` の単一ファイルにまとまっており、`#[cfg(test)]` で 14 ケース同居。低レベル基盤クレート (`llm-worker` 等) を汚染していない、TUI レイヤ内に閉じる正しい配置。 + +## 指摘事項 + +### Non-blocking / Follow-up +- HTML 取り扱いがチケット記載 (「タグはそのまま生テキストで出る」) と実装 (完全に破棄) で食い違う。実装側の方が UX 的に望ましいので、チケット側の文面を「HTML はそのまま無視する」に直すか、レビュー記録のままにしておくかは判断に委ねる。`crates/tui/src/markdown.rs:162-166`。 +- `span_style` 内で inline code / code block / heading が `self.base` を完全に無視している。Assistant の `kind_style` (`fg(White)`) しか base に来ない現状では実害ゼロだが、将来同じ `markdown::render` を `Thinking` (magenta + ITALIC) や `SystemMessage` (cyan) で使い回す際にコードブロックだけ palette から外れる。本チケットは Assistant のみが対象なので非ブロッキング。差すタイミングで「base を起点に code/heading の色相だけ寄せる」関数化を検討すると良い。`crates/tui/src/markdown.rs:70-94`。 +- 空のリスト項目 (`- a\n-\n- c` のような) は `pending_marker` が `flush_line` で消費される結果、`• ` だけの行が出る。`TagEnd::Item` のコメントは「marker was never consumed」と書いてあるが、現実には `flush_line` (current 空 + pending_marker Some) のガード条件をすり抜けて消費される (`crates/tui/src/markdown.rs:104-116`)。挙動として「空項目は空のバレットを 1 行出す」になっているのは妥当だが、コメントの意図と挙動がやや不一致。pending_marker を消費するか落とすかは別チケットでも構わない範囲。 + +### Nits +- `RULE_WIDTH` が 40 固定。ターミナル幅に応じた可変化は本チケットの完了条件外なので OK だが、`wrap_line_into` 経由で右側に折り返されない (40 < width 前提) ことだけ将来確認が要る。狭幅環境でも安全側 (はみ出さない) なので問題なし。 +- `pulldown_cmark::Options::ENABLE_STRIKETHROUGH` のみ有効。GFM のうち autolink / task list は今回対象外なので妥当。 + +## 判断 + +**Approve** — チケットの「対応する Markdown 要素」「範囲外」「完了条件」「影響範囲」のすべてに、コードとテストの両面で対応している。ストリーミング途中状態の堅牢性は CommonMark + pulldown-cmark 0.13 のセマンティクスに任せる方針が妥当に効いており、`wrap_line_into` との互換性も line-level style を空に保つことで担保できている。HTML 表示の文面ズレは非ブロッキング。