feat: TUIのmarkdown対応
This commit is contained in:
parent
fc5cfefb62
commit
6c95b2da56
18
Cargo.lock
generated
18
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ mod block;
|
|||
mod cache;
|
||||
mod client;
|
||||
mod input;
|
||||
mod markdown;
|
||||
mod picker;
|
||||
mod scroll;
|
||||
mod spawn;
|
||||
|
|
|
|||
386
crates/tui/src/markdown.rs
Normal file
386
crates/tui/src/markdown.rs
Normal 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("");
|
||||
// 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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -591,7 +591,7 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, 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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
49
tickets/tui-assistant-markdown.review.md
Normal file
49
tickets/tui-assistant-markdown.review.md
Normal 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` は付けていないので素通り。テーブル記号がそのまま見える形になるが、ストリーム自体は破綻しない。✓
|
||||
- 画像 ``: `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 表示の文面ズレは非ブロッキング。
|
||||
Loading…
Reference in New Issue
Block a user