diff --git a/Cargo.lock b/Cargo.lock index 0f1ac99..afd79f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3846,6 +3846,7 @@ dependencies = [ "kebab-app", "kebab-config", "kebab-core", + "pulldown-cmark", "ratatui", "serde_json", "tempfile", diff --git a/HANDOFF.md b/HANDOFF.md index 68bf2e2..00ea0ca 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -50,6 +50,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-07)** — Markdown title fallback chain. `kebab-normalize::derive_title(frontmatter_title, &[Block], file_stem)` — 1) frontmatter title → 2) 첫 H1 → 3) 첫 H2 → 4) 첫 paragraph 80 chars → 5) 파일 stem (모든 단계 NFC 정규화, 빈 문자열 절대 반환 안 함, 마지막 sentinel `"untitled"`). `build_canonical_document` 가 lift 후 helper 호출. parser_version 상수 `pulldown-cmark-0.x` → `md-frontmatter-v2` bump — 기존 doc 은 `doc_id` 가 갱신되므로 다음 ingest 가 자동 재처리 (idempotent upsert, design §9 cascade). spec: `tasks/p9/p9-fb-07-md-title-fallback.md`. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-09)** — TUI external editor return restore. Search `g` 키 (citation jump) 후 TUI 화면이 깨지는 버그 수정. `kebab-tui::editor::with_external_program(&mut TuiTerminal, Command)` helper 가 suspend (LeaveAlternateScreen + Show cursor + disable_raw_mode) → spawn → restore (enable_raw_mode + EnterAlternateScreen + Hide cursor + `terminal.clear()`) 시퀀스를 RAII guard 로 atomic 하게 묶음. `App.pending_editor: Option` + `App.force_redraw: bool` 추가 — 키 핸들러는 EditorRequest enqueue 만, 실제 spawn 은 run loop 가 `TuiTerminal` 핸들 들고 처리. 후속 task (p9-fb-20 의 citation jump 등) 가 같은 helper 위에 build. spec: `tasks/p9/p9-fb-09-tui-editor-restore.md`. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-14)** — TUI color theme module. `kebab-tui::theme::{Theme, Role, Palette}` 신규 — 16 개 Role (BorderActive/Title/Path/ModeLexical/ModeVector/ModeHybrid/Selected/Hint/Heading/Warning/Error/Success/CitationMarker/Bullet/Body/BorderInactive) 을 dark + light 두 팔레트가 exhaustive match 로 매핑. 모든 Pane (library/search/ask/inspect/run/error_popup) 의 inline `Style::default().fg(Color::*)` 호출이 `theme.style(Role::X)` 로 격리됨. `Config.ui.theme: String` (default `"dark"`) 신규. `App.theme: Theme` 가 `App::new` 에서 `Theme::from_name(&config.ui.theme)` 로 build — 알 수 없는 값은 dark fallback (config 가 typo 로 죽지 않음). `T` 키 runtime toggle 은 mode machine (p9-fb-12) 미진행이라 skip — config 만으로 결정. p9-fb-11 (ask markdown render) 의 Theme 의존성 unblock. spec: `tasks/p9/p9-fb-14-tui-color-theme.md`. +- **2026-05-03 P9 도그푸딩 후속 (p9-fb-11)** — TUI Ask 답변 본문 markdown 렌더. `kebab-tui::markdown::render(text, &Theme) -> Vec>` 신규 — `pulldown-cmark = "0.13"` 위에서 inline (bold/italic/strikethrough/inline code/link)·block (heading H1-H6, ordered/unordered list with nesting, fenced code block, table, blockquote `▎`, horizontal rule) 변환. heading H1/H2 = `Role::Heading`, H3+ = `Role::Title`, link = `Role::CitationMarker + UNDERLINE`, code = `Role::Hint`. ask `push_turn_lines` 가 grounded 답변에서만 markdown 렌더; refusal (`Role::Warning`) / streaming (`Role::Hint`) 은 raw 로 두어 role color 시그널 보존. CLI `kebab ask` 출력은 raw markdown 그대로 (terminal 호환성). 매 frame 재 parse — pulldown 토크나이저가 µs/KB 라 비용 무시. spec: `tasks/p9/p9-fb-11-ask-markdown-render.md`. ## 다음 task 후보 diff --git a/README.md b/README.md index a6f3ab4..77ca4df 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ kebab doctor | `kebab inspect doc ` / `kebab inspect chunk ` | raw record 보기 | | `kebab ask "" [--show-citations / --hide-citations]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요 | | `kebab doctor` | 설정/모델/DB 헬스 체크 | -| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw | +| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해) | | `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) | | `kebab eval run / compare` | golden query 회귀 측정 | diff --git a/crates/kebab-tui/Cargo.toml b/crates/kebab-tui/Cargo.toml index 80c10d3..619f175 100644 --- a/crates/kebab-tui/Cargo.toml +++ b/crates/kebab-tui/Cargo.toml @@ -24,6 +24,10 @@ serde_json = { workspace = true } # not display width, so a list cell with `한` (width 2) followed by `a` # (width 1) overflows by one column without explicit width accounting. unicode-width = "0.2" +# p9-fb-11: parse markdown answer bodies into styled `Span`/`Line`s. +# Same parser the ingest pipeline uses (kebab-parse-md) — keeps the +# tokenizer behavior aligned with what the corpus is normalized as. +pulldown-cmark = { version = "0.13", default-features = false } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/kebab-tui/src/ask.rs b/crates/kebab-tui/src/ask.rs index a365e30..2021d47 100644 --- a/crates/kebab-tui/src/ask.rs +++ b/crates/kebab-tui/src/ask.rs @@ -160,26 +160,41 @@ fn push_turn_lines( Span::raw(": "), Span::raw(question.to_string()), ])); - // p9-fb-16: refusal turn (caller passed Role::Warning) keeps the - // P9-3 visual distinction inside the transcript. Streaming turn - // fades to dim hint. Default is plain Body. - let answer_style = if let Some(role) = answer_role_override { - theme.style(role) + // p9-fb-11: render markdown (bold/italic/code/list/heading) when + // the answer is in a normal/grounded state. For refusal (Warning + // override) and streaming (Hint), force plain styled rendering so + // the role color stays visible — markdown styling on top would + // mask the "this is a refusal" / "this is in flight" signal. + let a_label_span = Span::styled( + a_label, + theme + .style(crate::theme::Role::Success) + .add_modifier(Modifier::BOLD), + ); + if let Some(role) = answer_role_override { + out.push(Line::from(vec![ + a_label_span, + Span::raw(": "), + Span::styled(answer.to_string(), theme.style(role)), + ])); } else if streaming { - theme.style(crate::theme::Role::Hint) + out.push(Line::from(vec![ + a_label_span, + Span::raw(": "), + Span::styled(answer.to_string(), theme.style(crate::theme::Role::Hint)), + ])); } else { - theme.style(crate::theme::Role::Body) - }; - out.push(Line::from(vec![ - Span::styled( - a_label, - theme - .style(crate::theme::Role::Success) - .add_modifier(Modifier::BOLD), - ), - Span::raw(": "), - Span::styled(answer.to_string(), answer_style), - ])); + // Grounded answer: split A label onto its own marker line, then + // append markdown-rendered body lines indented two spaces (so + // the transcript stays readable when the answer wraps). + out.push(Line::from(vec![a_label_span, Span::raw(":")])); + for body_line in crate::markdown::render(answer, theme) { + let mut spans: Vec> = Vec::with_capacity(body_line.spans.len() + 1); + spans.push(Span::raw(" ")); + spans.extend(body_line.spans); + out.push(Line::from(spans)); + } + } } fn render_bottom(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) { diff --git a/crates/kebab-tui/src/lib.rs b/crates/kebab-tui/src/lib.rs index f49cea2..a28a3e8 100644 --- a/crates/kebab-tui/src/lib.rs +++ b/crates/kebab-tui/src/lib.rs @@ -19,6 +19,7 @@ mod error_popup; mod ingest_progress; mod inspect; mod library; +mod markdown; mod run; mod search; mod terminal; diff --git a/crates/kebab-tui/src/markdown.rs b/crates/kebab-tui/src/markdown.rs new file mode 100644 index 0000000..1b4409c --- /dev/null +++ b/crates/kebab-tui/src/markdown.rs @@ -0,0 +1,618 @@ +//! p9-fb-11: render a markdown string to ratatui `Line`s with the +//! current `Theme`. +//! +//! Scope (per spec p9-fb-11): +//! - inline `**bold**`, `*italic*`, `` `code` `` → `Modifier::*` +//! - inline `[text](url)` → underline + `Role::CitationMarker` color +//! - block heading `#`-`######` → bold + role-graded color +//! - block list (`-` / `*` / `1.`) → indent + bullet glyph +//! - block code fence ```` ``` ```` → indented monospace lines +//! - block table `| col |` → row text, `|` separators preserved +//! - block blockquote `>` → left bar `▎` + dim +//! +//! Streaming: the caller re-renders the full answer text every +//! frame. The Ratatui-side cost is a few µs per kilobyte (pulldown +//! is tokenizer-fast), so re-parse is fine. Incomplete inline spans +//! (e.g. unterminated `**`) emit their literal characters as raw +//! text — `pulldown-cmark` treats them as Text events when no +//! closing marker shows up. +//! +//! Out of scope (per spec): images (terminal can't render them), +//! link click/follow (P+). + +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; + +use crate::theme::{Role, Theme}; + +/// Render a markdown answer into styled `Line`s. Always returns at +/// least one line — a fully-empty input emits a single empty line so +/// callers can scroll / measure without an `is_empty()` guard. +pub fn render(text: &str, theme: &Theme) -> Vec> { + if text.is_empty() { + return vec![Line::from("")]; + } + + let mut out: Vec> = Vec::new(); + // Pending Spans for the line currently under construction. + // Flushed into `out` on every Hard/SoftBreak or when a block + // boundary closes the current line. + let mut current: Vec> = Vec::new(); + // Stack of inline modifiers active right now (Strong / Emph + // nest, plus inline Code as a one-off bg/style flag). + let mut style_stack: Vec = Vec::new(); + // Heading level overrides the per-Text style with a Role-based + // color until End(Heading) pops it. + let mut heading_role: Option = None; + // Link target: when set, every Text event inside the link gets + // an underline + CitationMarker color. + let mut in_link: bool = false; + // List depth: 0 = no list, 1+ = nested. Indent grows with depth. + let mut list_depth: usize = 0; + // Ordered-list counter stack (one entry per ordered list level + // we've entered). Always pushed/popped in lockstep with list_depth + // — bullet-list entries push None (rendered as `-`). + let mut list_counters: Vec> = Vec::new(); + // Code-fence body — collected so it can be flushed as a block + // with each line indented + dim-styled. + let mut in_code_block: bool = false; + let mut code_block_buf: String = String::new(); + // Blockquote depth: render lines inside with a `▎` prefix. + let mut quote_depth: usize = 0; + + let parser = Parser::new_ext(text, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH); + + for event in parser { + match event { + Event::Start(tag) => match tag { + Tag::Heading { level, .. } => { + flush_current(&mut current, &mut out); + heading_role = Some(heading_role_for(level)); + } + Tag::Strong => style_stack.push(Modifier::BOLD), + Tag::Emphasis => style_stack.push(Modifier::ITALIC), + Tag::Strikethrough => style_stack.push(Modifier::CROSSED_OUT), + Tag::Link { .. } => in_link = true, + Tag::List(start) => { + flush_current(&mut current, &mut out); + list_depth += 1; + list_counters.push(start); + } + Tag::Item => { + flush_current(&mut current, &mut out); + let indent = " ".repeat(list_depth.saturating_sub(1)); + let bullet = match list_counters.last_mut() { + Some(Some(n)) => { + let s = format!("{n}. "); + *n += 1; + s + } + _ => "- ".to_string(), + }; + current.push(Span::raw(indent)); + current.push(Span::styled(bullet, theme.style(Role::Bullet))); + } + Tag::CodeBlock(kind) => { + flush_current(&mut current, &mut out); + in_code_block = true; + code_block_buf.clear(); + // pulldown emits `Tag::CodeBlock` once per fence; the + // language tag (rust, python, …) is informational — + // we don't syntax-highlight in v1, but log it for + // future reference. + if let CodeBlockKind::Fenced(lang) = kind { + if !lang.is_empty() { + tracing::trace!(target: "kebab-tui", %lang, "markdown code fence lang"); + } + } + } + Tag::BlockQuote(_) => { + flush_current(&mut current, &mut out); + quote_depth += 1; + } + Tag::Paragraph => { + // No-op on Start: the next Text event will start + // populating `current`. End(Paragraph) flushes. + } + Tag::Table(_) | Tag::TableHead | Tag::TableRow => { + flush_current(&mut current, &mut out); + } + Tag::TableCell => { + // Cell separator. Push `| ` prefix so a row + // renders as `| col1 | col2 |` (markdown-style). + if current.is_empty() { + current.push(Span::styled("| ", theme.style(Role::Bullet))); + } else { + current.push(Span::styled(" | ", theme.style(Role::Bullet))); + } + } + _ => {} + }, + Event::End(tag_end) => match tag_end { + TagEnd::Heading(_) => { + heading_role = None; + flush_current(&mut current, &mut out); + } + TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough => { + style_stack.pop(); + } + TagEnd::Link => in_link = false, + TagEnd::List(_) => { + flush_current(&mut current, &mut out); + list_depth = list_depth.saturating_sub(1); + list_counters.pop(); + } + TagEnd::Item => { + flush_current(&mut current, &mut out); + } + TagEnd::CodeBlock => { + flush_code_block(&mut code_block_buf, &mut out, theme); + in_code_block = false; + } + TagEnd::BlockQuote(_) => { + flush_current(&mut current, &mut out); + quote_depth = quote_depth.saturating_sub(1); + } + TagEnd::Paragraph => { + flush_current(&mut current, &mut out); + // Blank line between paragraphs for readability. + out.push(Line::from("")); + } + TagEnd::TableRow | TagEnd::TableHead => { + // Close the row with a trailing `|`. + current.push(Span::styled(" |", theme.style(Role::Bullet))); + flush_current(&mut current, &mut out); + } + TagEnd::Table => { + flush_current(&mut current, &mut out); + out.push(Line::from("")); + } + _ => {} + }, + Event::Text(t) => { + if in_code_block { + code_block_buf.push_str(&t); + } else { + let style = compose_style(theme, heading_role, &style_stack, in_link, false); + push_text_with_quote_prefix( + &mut current, + &mut out, + &t, + style, + quote_depth, + theme, + ); + } + } + Event::Code(c) => { + let style = compose_style(theme, heading_role, &style_stack, in_link, true); + current.push(Span::styled(c.into_string(), style)); + } + Event::SoftBreak | Event::HardBreak => { + flush_current(&mut current, &mut out); + } + Event::Rule => { + flush_current(&mut current, &mut out); + out.push(Line::from(Span::styled( + "─".repeat(40), + theme.style(Role::Bullet), + ))); + } + Event::Html(h) | Event::InlineHtml(h) => { + // Render raw HTML as text — terminal can't display + // tags. Use Hint role so it visually distinguishes + // from user-written prose. + current.push(Span::styled( + h.into_string(), + theme.style(Role::Hint), + )); + } + Event::InlineMath(s) | Event::DisplayMath(s) => { + // No LaTeX rendering in a terminal v1, but preserve + // the source so the answer's math still reaches the + // user as readable text instead of vanishing. + current.push(Span::styled( + s.into_string(), + theme.style(Role::Hint), + )); + } + Event::FootnoteReference(label) => { + // Render as `[^label]` so the footnote anchor is + // visible in the answer body. + current.push(Span::styled( + format!("[^{}]", label), + theme.style(Role::CitationMarker), + )); + } + Event::TaskListMarker(checked) => { + // GFM task lists — surface as `[x] ` / `[ ] ` so + // checklists stay legible in the answer. + let marker = if checked { "[x] " } else { "[ ] " }; + current.push(Span::styled(marker, theme.style(Role::Bullet))); + } + } + } + + // Flush any trailing line (e.g. a paragraph not yet closed — + // happens when input ends mid-line during streaming). + flush_current(&mut current, &mut out); + + if out.is_empty() { + out.push(Line::from("")); + } + out +} + +/// Map an MD heading level to a Role. H1 / H2 use `Heading` (Cyan + +/// BOLD in dark); H3+ degrade to `Title` (White + BOLD) so the +/// hierarchy stays visible without inventing new roles. +fn heading_role_for(level: HeadingLevel) -> Role { + match level { + HeadingLevel::H1 | HeadingLevel::H2 => Role::Heading, + _ => Role::Title, + } +} + +/// Compose the active inline style from the heading override (if any), +/// the modifier stack (Strong/Emph/Strikethrough), and the link / +/// inline-code flags. +/// +/// Layering rule: the **base color** comes from the most-specific +/// container — heading first, then link, then inline code, then body. +/// **Modifiers** from `style_stack` AND from link/inline-code overlay +/// on top regardless. So `# Section [docs](url) `code``: +/// - `docs` keeps the heading color (Cyan + BOLD) but also gains +/// `UNDERLINED` from the link, signalling "clickable text" without +/// losing the heading's hierarchy color. +/// - `code` keeps the heading color and adds `DIM` from inline-code. +fn compose_style( + theme: &Theme, + heading_role: Option, + style_stack: &[Modifier], + in_link: bool, + inline_code: bool, +) -> Style { + let base = if let Some(role) = heading_role { + theme.style(role) + } else if in_link { + theme.style(Role::CitationMarker) + } else if inline_code { + // Inline code — represent with Hint (DIM) since Terminal + // doesn't reliably do bg colors without 256-color, and italic + // is taken by Emphasis. Conservative-but-visible cue. + theme.style(Role::Hint) + } else { + theme.style(Role::Body) + }; + let mut acc = Modifier::empty(); + for m in style_stack { + acc.insert(*m); + } + if in_link { + acc.insert(Modifier::UNDERLINED); + } + if inline_code && heading_role.is_some() { + // Inside a heading, inline code keeps heading color but takes + // the DIM marker so it still reads as code. + acc.insert(Modifier::DIM); + } + base.add_modifier(acc) +} + +/// Push a text run into the current line, splitting on any embedded +/// `\n` (pulldown emits these inside paragraphs occasionally). Each +/// new line inherits the blockquote prefix. +fn push_text_with_quote_prefix( + current: &mut Vec>, + out: &mut Vec>, + text: &str, + style: Style, + quote_depth: usize, + theme: &Theme, +) { + if quote_depth > 0 && current.is_empty() { + current.push(quote_prefix(quote_depth, theme)); + } + let mut first = true; + for chunk in text.split('\n') { + if !first { + flush_current(current, out); + if quote_depth > 0 { + current.push(quote_prefix(quote_depth, theme)); + } + } + if !chunk.is_empty() { + current.push(Span::styled(chunk.to_string(), style)); + } + first = false; + } +} + +/// `▎` glyph repeated for nested quotes, dim-styled. +fn quote_prefix(depth: usize, theme: &Theme) -> Span<'static> { + Span::styled("▎".repeat(depth) + " ", theme.style(Role::Hint)) +} + +/// Move `current` into a new `Line` and clear it. No-op when empty. +fn flush_current(current: &mut Vec>, out: &mut Vec>) { + if current.is_empty() { + return; + } + let line: Vec> = std::mem::take(current); + out.push(Line::from(line)); +} + +/// Flush a captured code-fence body. Each source line becomes one +/// output `Line`, indented ` ` and `Hint`-styled (DIM) so it visually +/// stands apart from prose. A blank line follows the block. +fn flush_code_block(buf: &mut String, out: &mut Vec>, theme: &Theme) { + if buf.is_empty() { + return; + } + for line in buf.lines() { + out.push(Line::from(Span::styled( + format!(" {line}"), + theme.style(Role::Hint), + ))); + } + out.push(Line::from("")); + buf.clear(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn theme() -> Theme { + Theme::dark() + } + + /// Empty input still produces a single empty line so callers can + /// scroll / measure without an `is_empty()` guard. + #[test] + fn empty_input_returns_one_empty_line() { + let lines = render("", &theme()); + assert_eq!(lines.len(), 1); + assert_eq!(line_text(&lines[0]), ""); + } + + /// Plain text emits one Line with one Span (no styling beyond + /// `Role::Body`'s default). + #[test] + fn plain_text_one_paragraph_one_line() { + let lines = render("hello world", &theme()); + // Paragraph end emits a blank line, so 2 lines total: text + blank. + assert_eq!(lines.len(), 2); + assert_eq!(line_text(&lines[0]), "hello world"); + assert_eq!(line_text(&lines[1]), ""); + } + + /// `**bold**` produces a Span with BOLD modifier. + #[test] + fn bold_emits_bold_modifier() { + let lines = render("**hi**", &theme()); + let bold_spans: Vec<&Span> = lines + .iter() + .flat_map(|l| l.spans.iter()) + .filter(|s| s.style.add_modifier.contains(Modifier::BOLD)) + .collect(); + assert!(!bold_spans.is_empty(), "expected at least one BOLD span"); + let combined: String = bold_spans.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(combined, "hi"); + } + + /// `*italic*` produces a Span with ITALIC modifier. + #[test] + fn italic_emits_italic_modifier() { + let lines = render("*hi*", &theme()); + let italic_spans: Vec<&Span> = lines + .iter() + .flat_map(|l| l.spans.iter()) + .filter(|s| s.style.add_modifier.contains(Modifier::ITALIC)) + .collect(); + assert!(!italic_spans.is_empty(), "expected at least one ITALIC span"); + let combined: String = italic_spans.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(combined, "hi"); + } + + /// Inline `` `code` `` emits a span with the inline-code style + /// (DIM in our v1 mapping). Content matches the literal. + #[test] + fn inline_code_emits_styled_span() { + let lines = render("call `frob()` here", &theme()); + let code_spans: Vec<&Span> = lines + .iter() + .flat_map(|l| l.spans.iter()) + .filter(|s| s.content.as_ref() == "frob()") + .collect(); + assert_eq!(code_spans.len(), 1); + assert!( + code_spans[0].style.add_modifier.contains(Modifier::DIM) + || code_spans[0].style.fg.is_some() + || code_spans[0].style.bg.is_some(), + "inline code span carries no style: {:?}", + code_spans[0].style + ); + } + + /// p9-fb-11 R1: link inside a heading layers — heading color + /// stays (Cyan + BOLD) AND link's UNDERLINE marker is added. + #[test] + fn link_inside_heading_layers_underline_on_heading_color() { + let lines = render("# Section [docs](https://x)", &theme()); + let docs = lines + .iter() + .flat_map(|l| l.spans.iter()) + .find(|s| s.content.as_ref() == "docs") + .expect("link text span"); + assert!( + docs.style.add_modifier.contains(Modifier::UNDERLINED), + "link inside heading should still get UNDERLINED: {:?}", + docs.style + ); + assert!( + docs.style.add_modifier.contains(Modifier::BOLD), + "link inside heading should keep heading BOLD: {:?}", + docs.style + ); + } + + /// p9-fb-11 R1: math expressions render as text (they used to be + /// silently dropped, losing answer content). + #[test] + fn inline_and_display_math_render_as_text() { + let inline = render("see $E = mc^2$ here", &theme()); + let combined: String = inline.iter().map(line_text).collect::>().join(""); + assert!( + combined.contains("E = mc^2"), + "inline math content dropped: {combined:?}" + ); + let display = render("$$\\sum_i x_i$$", &theme()); + let combined: String = display.iter().map(line_text).collect::>().join(""); + assert!( + combined.contains("\\sum_i x_i") || combined.contains("sum_i x_i"), + "display math content dropped: {combined:?}" + ); + } + + /// p9-fb-11 R1: GFM task lists render as `[ ] ` / `[x] `. + #[test] + fn task_list_renders_checkbox_glyphs() { + let md = "- [ ] todo\n- [x] done"; + let lines = render(md, &theme()); + let texts: Vec = lines.iter().map(line_text).collect(); + assert!( + texts.iter().any(|t| t.contains("[ ] todo")), + "unchecked task missing: {texts:?}" + ); + assert!( + texts.iter().any(|t| t.contains("[x] done")), + "checked task missing: {texts:?}" + ); + } + + /// `[text](https://x)` underlines `text`. + #[test] + fn link_underlines_text() { + let lines = render("see [docs](https://example.com)", &theme()); + let link_span = lines + .iter() + .flat_map(|l| l.spans.iter()) + .find(|s| s.content.as_ref() == "docs") + .expect("link text span present"); + assert!( + link_span.style.add_modifier.contains(Modifier::UNDERLINED), + "link span missing UNDERLINE: {:?}", + link_span.style + ); + } + + /// Heading `# Title` styles the title with the H1 Role::Heading + /// (Cyan + BOLD in dark). + #[test] + fn heading_h1_styles_title() { + let lines = render("# Title here", &theme()); + let title_span = lines + .iter() + .flat_map(|l| l.spans.iter()) + .find(|s| s.content.as_ref().contains("Title here")) + .expect("heading text span"); + assert!(title_span.style.add_modifier.contains(Modifier::BOLD)); + } + + /// `- item` emits a bullet glyph + indented item text. + #[test] + fn bullet_list_renders_dash_prefix() { + let lines = render("- first\n- second", &theme()); + let texts: Vec = lines.iter().map(line_text).collect(); + assert!(texts.iter().any(|t| t.starts_with("- first"))); + assert!(texts.iter().any(|t| t.starts_with("- second"))); + } + + /// `1.` / `2.` numbered list prefixes with the actual numbers. + #[test] + fn ordered_list_renders_numbered_prefix() { + let lines = render("1. alpha\n2. beta", &theme()); + let texts: Vec = lines.iter().map(line_text).collect(); + assert!(texts.iter().any(|t| t.starts_with("1. alpha"))); + assert!(texts.iter().any(|t| t.starts_with("2. beta"))); + } + + /// Code fence body is preserved verbatim per line, indented two + /// spaces. + #[test] + fn code_fence_preserves_body_lines() { + let md = "```rust\nlet x = 1;\nlet y = 2;\n```"; + let lines = render(md, &theme()); + let texts: Vec = lines.iter().map(line_text).collect(); + assert!(texts.iter().any(|t| t == " let x = 1;")); + assert!(texts.iter().any(|t| t == " let y = 2;")); + } + + /// Blockquote `> hi` prefixes the line with `▎`. + #[test] + fn blockquote_renders_left_bar() { + let lines = render("> quoted text", &theme()); + let texts: Vec = lines.iter().map(line_text).collect(); + assert!( + texts.iter().any(|t| t.starts_with("▎")), + "no `▎` prefix in: {texts:?}" + ); + } + + /// 2x2 table renders as `| col | col |` rows. We don't promote to + /// `Table` widget since the answer area uses Paragraph-flow. + #[test] + fn table_renders_pipe_separated_rows() { + let md = "| a | b |\n| - | - |\n| 1 | 2 |"; + let lines = render(md, &theme()); + let texts: Vec = lines.iter().map(line_text).collect(); + // header row + body row, both with `|` separators + assert!( + texts.iter().any(|t| t.contains("| a") && t.contains("b |")), + "header row missing pipes: {texts:?}" + ); + assert!( + texts.iter().any(|t| t.contains("| 1") && t.contains("2 |")), + "body row missing pipes: {texts:?}" + ); + } + + /// Streaming partial: an unterminated `**` MUST NOT drop the + /// content text. pulldown-cmark 0.13 emits the suffix as a Text + /// event (with or without preserving the `**` literal — both are + /// acceptable as long as `still typing` reaches the output). + /// Splitting the assertion: content presence is a hard constraint + /// (regression catches `pulldown` upgrades that lose characters); + /// the literal `**` is cosmetic and not pinned. + #[test] + fn unterminated_bold_does_not_drop_content() { + let lines = render("**still typing", &theme()); + let combined: String = lines.iter().map(line_text).collect::>().join(""); + assert!( + combined.contains("still typing"), + "stream-mid output dropped content text: {combined:?}" + ); + } + + /// Composite snapshot — heading + paragraph + list + code render + /// in document order without swallowing content. + #[test] + fn composite_snapshot_preserves_document_order() { + let md = "# Goal\n\nDescription **here**.\n\n- alpha\n- beta\n\n```\nlet x = 1;\n```"; + let lines = render(md, &theme()); + let texts: Vec = lines.iter().map(line_text).collect(); + let heading_idx = texts.iter().position(|t| t.contains("Goal")).unwrap(); + let para_idx = texts.iter().position(|t| t.contains("Description")).unwrap(); + let alpha_idx = texts.iter().position(|t| t.contains("alpha")).unwrap(); + let code_idx = texts.iter().position(|t| t.contains("let x = 1;")).unwrap(); + assert!(heading_idx < para_idx); + assert!(para_idx < alpha_idx); + assert!(alpha_idx < code_idx); + } + + fn line_text(line: &Line<'_>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } +} diff --git a/tasks/p9/p9-fb-11-ask-markdown-render.md b/tasks/p9/p9-fb-11-ask-markdown-render.md index 705fe31..dcdbdd8 100644 --- a/tasks/p9/p9-fb-11-ask-markdown-render.md +++ b/tasks/p9/p9-fb-11-ask-markdown-render.md @@ -3,7 +3,7 @@ phase: P9 component: kebab-tui (ask pane) task_id: p9-fb-11 title: "Ask answer markdown rendering (bold/italic/code/list/table)" -status: planned +status: in_progress depends_on: [p9-fb-14] unblocks: [] contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md