feat: TUIのmarkdown対応

This commit is contained in:
Keisuke Hirata 2026-05-05 18:30:25 +09:00
parent fc5cfefb62
commit 6c95b2da56
7 changed files with 462 additions and 1 deletions

18
Cargo.lock generated
View File

@ -2250,6 +2250,17 @@ dependencies = [
"wiremock", "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]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@ -3614,6 +3625,7 @@ dependencies = [
"manifest", "manifest",
"pod-registry", "pod-registry",
"protocol", "protocol",
"pulldown-cmark",
"ratatui", "ratatui",
"serde", "serde",
"serde_json", "serde_json",
@ -3637,6 +3649,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"

View File

@ -17,6 +17,7 @@ manifest = { workspace = true }
session-store = { workspace = true } session-store = { workspace = true }
pod-registry = { workspace = true } pod-registry = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
pulldown-cmark = { version = "0.13.3", default-features = false }
[dev-dependencies] [dev-dependencies]
tools = { workspace = true } tools = { workspace = true }

View File

@ -3,6 +3,7 @@ mod block;
mod cache; mod cache;
mod client; mod client;
mod input; mod input;
mod markdown;
mod picker; mod picker;
mod scroll; mod scroll;
mod spawn; mod spawn;

386
crates/tui/src/markdown.rs Normal file
View File

@ -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<Line<'static>> {
let mut out: Vec<Line<'static>> = 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<Span<'static>>,
pending_marker: Option<Span<'static>>,
current: Vec<Span<'static>>,
bold: u32,
italic: u32,
strike: u32,
in_link: u32,
in_inline_code: u32,
image_depth: u32,
heading: Option<HeadingLevel>,
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<Option<u64>>,
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<Line<'static>>) {
if self.current.is_empty() && self.pending_marker.is_none() {
return;
}
let mut spans: Vec<Span<'static>> = 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<Line<'static>>) {
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<Line<'static>>) {
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<Line<'static>>) {
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<Line<'static>>) {
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<Line<'static>>) {
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<String> {
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"]);
}
}

View File

@ -591,7 +591,7 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
} }
Block::AssistantText { text } => match mode { Block::AssistantText { text } => match mode {
Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""), 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), Block::Thinking(t) => render_thinking(lines, t, width, mode),
// ToolCall is dispatched in `compute_history` via `tool::render_tool` // ToolCall is dispatched in `compute_history` via `tool::render_tool`

View File

@ -80,3 +80,9 @@ span 単位のラップを既に実装しているため、Markdown レンダラ
- `crates/tui/src/ui.rs`: `Block::AssistantText` 分岐で Markdown - `crates/tui/src/ui.rs`: `Block::AssistantText` 分岐で Markdown
レンダラを呼ぶ。`Mode::Overview` は現行のまま。 レンダラを呼ぶ。`Mode::Overview` は現行のまま。
- `crates/tui/src/main.rs` または `lib.rs`: 新モジュールの宣言。 - `crates/tui/src/main.rs` または `lib.rs`: 新モジュールの宣言。
## Review
- 状態: Approve
- レビュー詳細: [./tui-assistant-markdown.review.md](./tui-assistant-markdown.review.md)
- 日付: 2026-05-05

View File

@ -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 で `<div>hi</div>` 入りの入力に対し `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<Line<'static>>` の 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 表示の文面ズレは非ブロッキング。