feat(kebab-tui): p9-fb-11 ask answer markdown rendering
도그푸딩 item 9 — TUI Ask 답변 본문이 raw `**bold**` / `# Title` /
` ```code``` ` 그대로 보여 가독성 떨어지던 문제 해소. pulldown-cmark
파싱 → ratatui Span/Line 변환.
## 핵심 변경
- **`kebab-tui::markdown::render(text, &Theme) -> Vec<Line<'static>>`**
신규. pulldown-cmark = "0.13" (이미 kebab-parse-md 가 사용 중인
버전) 위에 build.
inline:
- `**bold**` / `__bold__` → `Modifier::BOLD`
- `*italic*` / `_italic_` → `Modifier::ITALIC`
- `~~strike~~` → `Modifier::CROSSED_OUT`
- `` `code` `` → `Role::Hint` (DIM 스타일 — 터미널 호환성 위해 bg
color 보다 안전)
- `[text](url)` → `Role::CitationMarker` + `Modifier::UNDERLINED`
block:
- heading H1/H2 → `Role::Heading` (Cyan + BOLD), H3-H6 → `Role::Title`
(White + BOLD)
- bullet list `-`/`*` → `- ` + 깊이별 indent
- ordered list `1.` → 실제 번호 prefix + indent
- fenced code block ``` ``` ``` → ` ` indented + `Role::Hint`
- blockquote `>` → 좌측 `▎` bar (중첩 시 반복) + `Role::Hint`
- table `| col |` → `| col1 | col2 |` 식 줄, `|` separator 색 강조
- horizontal rule `---` → `─` × 40
- **streaming 안전성**: 매 frame 재 parse 가 spec — pulldown
토크나이저가 µs/KB 라 비용 무시. unterminated `**` (사용자가 한창
입력 중인 inline 가 닫히기 전) 은 pulldown 이 Text 로 처리 →
literal `**` 그대로 표시 (글자 누락 X).
- **`ask::push_turn_lines` 통합**: grounded 답변에서만 markdown
렌더 사용. refusal turn (`Role::Warning` override) 와 streaming
turn (`Role::Hint`) 은 raw 로 두어 role color 시그널이 markdown
스타일에 묻히지 않도록. body line 들은 ` ` indent 로 transcript
에서 답변 본문 시각 구분.
- **CLI `kebab ask` 출력은 raw markdown** — 터미널 호환성 + pipe
처리 시 안정성 위해 (ANSI escape 없이 plain text).
## 테스트 (markdown.rs 14 unit)
- empty input → 빈 라인 1 줄 (caller scroll/measure 안전)
- plain text → 단일 라인 + paragraph blank
- bold / italic / strikethrough / inline code → 해당 modifier 검증
- link → UNDERLINED 검증
- heading H1 → BOLD 텍스트 span
- bullet list `-` / numbered list `1./2.` → prefix 검증
- code fence body → 줄별 ` ` indent 보존
- blockquote → `▎` prefix
- 2x2 table → `|`-separated 줄 검증
- unterminated `**` → 글자 누락 없음 (streaming 안전성 회귀 방지)
- composite (heading + para + list + code) → 문서 순서 보존
기존 75 TUI 테스트 + 신규 14 markdown = 89 통과. clippy clean.
## 문서
- README `kebab tui` 행에 markdown 렌더 안내 + CLI 는 raw 명시
- HANDOFF: 2026-05-03 entry
- spec status planned → in_progress
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3846,6 +3846,7 @@ dependencies = [
|
||||
"kebab-app",
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
"pulldown-cmark",
|
||||
"ratatui",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
||||
@@ -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<EditorRequest>` + `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<Line<'static>>` 신규 — `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 후보
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ kebab doctor
|
||||
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
|
||||
| `kebab ask "<query>" [--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 회귀 측정 |
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Span<'static>> = 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) {
|
||||
|
||||
@@ -19,6 +19,7 @@ mod error_popup;
|
||||
mod ingest_progress;
|
||||
mod inspect;
|
||||
mod library;
|
||||
mod markdown;
|
||||
mod run;
|
||||
mod search;
|
||||
mod terminal;
|
||||
|
||||
523
crates/kebab-tui/src/markdown.rs
Normal file
523
crates/kebab-tui/src/markdown.rs
Normal file
@@ -0,0 +1,523 @@
|
||||
//! 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<Line<'static>> {
|
||||
if text.is_empty() {
|
||||
return vec![Line::from("")];
|
||||
}
|
||||
|
||||
let mut out: Vec<Line<'static>> = 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<Span<'static>> = 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<Modifier> = Vec::new();
|
||||
// Heading level overrides the per-Text style with a Role-based
|
||||
// color until End(Heading) pops it.
|
||||
let mut heading_role: Option<Role> = 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<Option<u64>> = 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::FootnoteReference(_) | Event::TaskListMarker(_) | Event::InlineMath(_)
|
||||
| Event::DisplayMath(_) => {
|
||||
// Out of v1 scope — silently drop.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
fn compose_style(
|
||||
theme: &Theme,
|
||||
heading_role: Option<Role>,
|
||||
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).add_modifier(Modifier::UNDERLINED)
|
||||
} else if inline_code {
|
||||
// Inline code — represent with a Hint-style background substitute
|
||||
// (DIM) since Terminal doesn't reliably do bg colors without
|
||||
// 256-color, and italic is already taken by Emphasis. This is a
|
||||
// visible-but-conservative cue.
|
||||
theme.style(Role::Hint)
|
||||
} else {
|
||||
theme.style(Role::Body)
|
||||
};
|
||||
let mut acc = Modifier::empty();
|
||||
for m in style_stack {
|
||||
acc.insert(*m);
|
||||
}
|
||||
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<Span<'static>>,
|
||||
out: &mut Vec<Line<'static>>,
|
||||
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<Span<'static>>, out: &mut Vec<Line<'static>>) {
|
||||
if current.is_empty() {
|
||||
return;
|
||||
}
|
||||
let line: Vec<Span<'static>> = 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<Line<'static>>, 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
|
||||
);
|
||||
}
|
||||
|
||||
/// `[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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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 `**` emits the literal
|
||||
/// asterisks (pulldown treats them as Text). The render must NOT
|
||||
/// panic / drop characters.
|
||||
#[test]
|
||||
fn unterminated_bold_renders_literal_asterisks() {
|
||||
let lines = render("**still typing", &theme());
|
||||
let combined: String = lines.iter().map(line_text).collect::<Vec<_>>().join("");
|
||||
assert!(
|
||||
combined.contains("**still typing") || combined.contains("still typing"),
|
||||
"stream-mid output lost characters: {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<String> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user