Merge pull request 'feat(tui): Ask conversation transcript UI (p9-fb-16)' (#62) from feat/p9-fb-16-tui-conv into main

This commit was merged in pull request #62.
This commit is contained in:
2026-05-03 00:03:16 +00:00
6 changed files with 395 additions and 53 deletions

View File

@@ -45,6 +45,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
- **2026-05-02 P9 도그푸딩 후속 (p9-fb-03)** — TUI 의 background ingest worker. Library 의 `r` 키가 `kebab_app::ingest_with_config_progress` 를 spawned thread 에서 호출, run loop 가 매 frame 마다 progress channel drain → 화면 하단 status bar 1 줄 갱신. terminal event (`Completed`/`Aborted`) 후 3 초 final 라인 hold + 자동 hide + Library auto-refresh. spec: `tasks/p9/p9-fb-03-tui-ingest-background.md`. (cancel slot 은 p9-fb-04 가 추가하는 형태로 단일화 — 회차 1 review 결과.)
- **2026-05-02 P9 도그푸딩 후속 (p9-fb-04)** — ingest cooperative cancellation. `kebab-app::ingest_with_config_cancellable(.., cancel: Option<Arc<AtomicBool>>)` facade 추가, 기존 `_progress``cancel=None` forwarding. asset loop iter 시작 boundary 마다 cancel poll → true 면 break + `IngestEvent::Aborted { partial_counts }` + `Ok(IngestReport)` 정상 반환 (Err 아님). 부분 commit 보존, 다음 ingest 가 idempotent 재개. CLI Ctrl-C SIGINT handler (`ctrlc` crate) — 1회: cancel, 2회: hard exit (130). TUI Esc / Ctrl-C 가 cancel signal (in-flight 시), 그 외에는 quit. `IngestState``cancel: Arc<AtomicBool>` field 추가. spec: `tasks/p9/p9-fb-04-ingest-cancellation.md`.
- **2026-05-02 P9 도그푸딩 후속 (spec PR #59 + p9-fb-15)** — RAG multi-turn 도입. frozen design §3.8 갱신 — `Answer``conversation_id` / `turn_index` optional field, 신규 `Turn` struct, `RefusalReason::LlmStreamAborted` variant. `kebab-rag::AskOpts``history: Vec<Turn>` / `conversation_id` / `turn_index` 3 field 추가, 기존 caller 는 `Vec::new() / None` (single-shot 동작 동일). `RagPipeline::ask_with_history(query, history, conversation_id, turn_index, opts)` helper. prompt 빌드: `[이전 대화]` 블록을 user prompt 위에 prepend, newest-first, char budget (`cfg.rag.max_context_tokens * 4`) 안에서 oldest 부터 drop. retrieval query expansion: 직전 answer 첫 200 자 concat. wire schema `answer.v1` 에 두 필드 + `format: date-time` 추가. p9-fb-16 (TUI conversation UI) + p9-fb-17/18 (V004 storage + CLI session) 가 같은 facade 위에 build. spec: `tasks/p9/p9-fb-15-rag-multi-turn-core.md`.
- **2026-05-02 P9 도그푸딩 후속 (p9-fb-16)** — TUI Ask conversation UI. `AskState``turns: Vec<Turn>` + `current_question` + `conversation_id` + `last_answer` 로 재설계. answer area 가 transcript (`Q1/A1`, `Q2/A2`, ...) 로 갈음, 매 Enter 가 이전 turns 를 `history` 로 worker 에 전달 (`ask_with_history`). conversation_id 는 첫 submit 시 timestamp-based 자동 생성 (`conv_<unix_nanos_hex>`). `Ctrl-L` 가 turns + conversation_id 초기화 (in-flight worker 는 그대로 finish, 결과는 새 conversation 의 stale turn 으로 silently 폐기). spec: `tasks/p9/p9-fb-16-tui-ask-conversation.md`.
## 다음 task 후보

View File

@@ -76,7 +76,7 @@ kebab doctor
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
| `kebab ask "<query>"` | RAG 답변 + 근거 인용. 근거 부족 시 거절. 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) |
| `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 시작 |
| `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 회귀 측정 |

View File

@@ -92,11 +92,20 @@ impl Default for SearchState {
}
}
/// Ask pane state — owned by p9-3.
/// Ask pane state — owned by p9-3, extended by p9-fb-16 for
/// multi-turn conversation transcript.
///
/// The worker thread (`thread`) owns the `mpsc::Sender<String>` that
/// `kebab-app::ask` writes tokens into. The pane keeps the matching
/// `rx` and drains it once per render frame (no blocking).
///
/// p9-fb-16: completed `Turn`s accumulate in `turns`; the worker
/// passes a snapshot of `turns` as `history` to
/// `RagPipeline::ask_with_history`, so each follow-up question sees
/// the full prior conversation. `conversation_id` is auto-generated
/// on the first submission (timestamp-based — unique per session,
/// not cryptographic). `Ctrl-L` clears `turns + conversation_id` to
/// start a fresh conversation.
#[derive(Default)]
pub struct AskState {
pub input: String,
@@ -105,20 +114,38 @@ pub struct AskState {
/// True between `Enter` press and worker thread completion.
pub streaming: bool,
/// Tokens accumulated from the worker so far. Cleared on each
/// new submission.
/// new submission. Mid-stream this is what the transcript shows
/// for the in-flight turn.
pub partial: String,
/// Final `Answer` once the worker thread finishes.
pub answer: Option<kebab_core::Answer>,
/// In-flight worker; `take()`n when it finishes.
pub thread: Option<std::thread::JoinHandle<anyhow::Result<kebab_core::Answer>>>,
/// Token receiver paired with the worker's `Sender`. Drained
/// every render frame.
pub rx: Option<std::sync::mpsc::Receiver<String>>,
/// Vertical scroll offset for the answer area when content
/// Vertical scroll offset for the transcript area when content
/// exceeds the viewport.
pub scroll: u16,
/// Last error from the worker thread (rendered in popup if Some).
pub last_error: Option<String>,
/// p9-fb-16: completed turns of the current conversation. Each
/// turn = (question, full answer text, citations, ts). Streaming
/// turn (the one being generated right now) lives in
/// `current_question` + `partial` and only graduates into
/// `turns` on `poll_worker` completion.
pub turns: Vec<kebab_core::Turn>,
/// p9-fb-16: question text for the in-flight turn. Cleared at
/// submission (input → current_question, input → empty),
/// finalized into the new Turn at completion.
pub current_question: Option<String>,
/// p9-fb-16: shared id stamped onto every `Answer` of this
/// conversation. Auto-generated on first submission, cleared by
/// `Ctrl-L` (next submission generates a fresh id).
pub conversation_id: Option<String>,
/// p9-fb-16: most-recent `Answer` for citation / status display
/// in the right panel. Same data also lives inside the last
/// `Turn`; this slot is just the easiest place for the panel
/// renderer to look.
pub last_answer: Option<kebab_core::Answer>,
}

View File

@@ -70,47 +70,110 @@ fn render_input(f: &mut Frame, area: Rect, s: &AskState) {
Span::styled(busy, Style::default().add_modifier(Modifier::DIM)),
]);
let block = Block::default()
.title("ask (Enter=submit e=explain Esc=back)")
.title("ask (Enter=submit e=explain Ctrl-L=new conversation Esc=back)")
.borders(Borders::ALL);
f.render_widget(Paragraph::new(line).block(block), area);
}
fn render_answer(f: &mut Frame, area: Rect, s: &AskState) {
let block = Block::default().title("answer").borders(Borders::ALL);
let title = if s.turns.is_empty() && !s.streaming {
"transcript".to_string()
} else {
let count = s.turns.len() + if s.streaming { 1 } else { 0 };
format!("transcript ({} turn{})", count, if count == 1 { "" } else { "s" })
};
let block = Block::default().title(title).borders(Borders::ALL);
// p9-fb-16: render the full conversation as Q/A pairs.
// Completed turns first (chronological), then the in-flight
// turn (if any) at the bottom. The most-recent completed
// turn's grounded flag (from `last_answer`) styles its A line
// — yellow on refusal so the user keeps the P9-3 visual
// distinction even inside the transcript.
let last_turn_grounded = s.last_answer.as_ref().map(|a| a.grounded);
let last_turn_idx = s.turns.len().saturating_sub(1);
let mut lines: Vec<Line> = Vec::new();
for (idx, turn) in s.turns.iter().enumerate() {
let style_override = if idx == last_turn_idx {
last_turn_grounded.and_then(|g| if g { None } else { Some(Color::Yellow) })
} else {
None
};
push_turn_lines(
&mut lines,
idx,
&turn.question,
&turn.answer,
false,
style_override,
);
lines.push(Line::raw(""));
}
if s.streaming {
// Mid-stream: show partial + cursor block.
let mut content = s.partial.clone();
content.push('▍');
let para = Paragraph::new(content)
.wrap(Wrap { trim: false })
.scroll((s.scroll, 0));
f.render_widget(para.block(block), area);
let q = s.current_question.as_deref().unwrap_or("");
let mut a = s.partial.clone();
a.push('▍');
let idx = s.turns.len();
push_turn_lines(&mut lines, idx, q, &a, true, None);
}
if lines.is_empty() {
let hint = Paragraph::new(Span::styled(
"(type a question and press Enter. follow-ups inherit history. Ctrl-L clears the conversation.)",
Style::default().add_modifier(Modifier::DIM),
))
.wrap(Wrap { trim: false });
f.render_widget(hint.block(block), area);
return;
}
if let Some(answer) = &s.answer {
// Style refusal answers (grounded=false) in yellow so the user
// immediately spots they're not getting a sourced answer.
let style = if answer.grounded {
let para = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((s.scroll, 0));
f.render_widget(para.block(block), area);
}
fn push_turn_lines(
out: &mut Vec<Line<'static>>,
idx: usize,
question: &str,
answer: &str,
streaming: bool,
answer_color_override: Option<Color>,
) {
let q_label = format!("Q{}", idx + 1);
let a_label = format!("A{}", idx + 1);
out.push(Line::from(vec![
Span::styled(
q_label,
Style::default()
} else {
Style::default().fg(Color::Yellow)
};
let para = Paragraph::new(Span::styled(answer.answer.as_str(), style))
.wrap(Wrap { trim: false })
.scroll((s.scroll, 0));
f.render_widget(para.block(block), area);
return;
}
// No question yet.
let hint = Paragraph::new(Span::styled(
"(type a question and press Enter to ask. RAG answers stream token-by-token.)",
Style::default().add_modifier(Modifier::DIM),
))
.wrap(Wrap { trim: false });
f.render_widget(hint.block(block), area);
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(": "),
Span::raw(question.to_string()),
]));
// p9-fb-16: refusal turn (caller passed Yellow) keeps the P9-3
// visual distinction inside the transcript. Streaming turn fades
// to dim gray. Default is plain.
let answer_style = if let Some(c) = answer_color_override {
Style::default().fg(c)
} else if streaming {
Style::default().fg(Color::Gray)
} else {
Style::default()
};
out.push(Line::from(vec![
Span::styled(
a_label,
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(": "),
Span::styled(answer.to_string(), answer_style),
]));
}
fn render_bottom(f: &mut Frame, area: Rect, s: &AskState) {
@@ -124,7 +187,7 @@ fn render_bottom(f: &mut Frame, area: Rect, s: &AskState) {
fn render_status(f: &mut Frame, area: Rect, s: &AskState) {
let block = Block::default().title("status").borders(Borders::ALL);
let lines: Vec<Line> = match &s.answer {
let lines: Vec<Line> = match &s.last_answer {
None => vec![Line::from(Span::styled(
"(no answer yet)",
Style::default().add_modifier(Modifier::DIM),
@@ -160,7 +223,7 @@ fn render_status(f: &mut Frame, area: Rect, s: &AskState) {
fn render_citations_or_explain(f: &mut Frame, area: Rect, s: &AskState) {
let title = if s.explain { "explain (per-claim)" } else { "citations" };
let block = Block::default().title(title).borders(Borders::ALL);
let lines: Vec<Line> = match &s.answer {
let lines: Vec<Line> = match &s.last_answer {
None => vec![Line::from(Span::styled(
"(submit a question to see citations)",
Style::default().add_modifier(Modifier::DIM),
@@ -200,6 +263,31 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome {
}
match (key.code, key.modifiers) {
// p9-fb-16: Ctrl-L clears the in-pane conversation (turns +
// conversation_id). Doesn't kill the in-flight worker — that
// turn still finishes and its result is silently discarded
// (joined into a new conversation that didn't exist when the
// worker was spawned). Behaviour mirrors `:new` slash command.
(KeyCode::Char('l'), m) if m.contains(KeyModifiers::CONTROL) => {
let s = state.ask.as_mut().unwrap();
s.turns.clear();
s.conversation_id = None;
s.last_answer = None;
s.partial.clear();
s.current_question = None;
s.scroll = 0;
// p9-fb-16: detach the in-flight worker so its eventual
// result does NOT graduate into the new conversation as
// a stale Turn. JoinHandle Drop on `None` assignment is
// the same detach pattern P9-3 uses for Esc cancel —
// worker keeps running in the background, finishes its
// SQLite `answers` write (the failed-conv attempt is
// preserved on disk), TUI ignores the result.
s.thread = None;
s.rx = None;
s.streaming = false;
KeyOutcome::Continue
}
(KeyCode::Esc, _) => {
// Best-effort cancellation per spec — worker keeps running
// but its result is dropped. Detach by clearing rx /
@@ -209,6 +297,7 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome {
s.rx = None;
s.thread = None;
s.streaming = false;
s.current_question = None;
KeyOutcome::SwitchPane(Pane::Library)
}
(KeyCode::Enter, _) => {
@@ -289,10 +378,21 @@ fn spawn_ask_worker(state: &mut App) {
let query = s.input.clone();
let explain = s.explain;
s.partial.clear();
s.answer = None;
s.last_answer = None;
s.streaming = true;
s.scroll = 0;
s.rx = Some(rx);
// p9-fb-16: graduate the typed input into the in-flight turn,
// clear the input box, ensure conversation_id exists, snapshot
// history for the worker.
s.current_question = Some(query.clone());
s.input.clear();
if s.conversation_id.is_none() {
s.conversation_id = Some(make_conversation_id());
}
let conversation_id = s.conversation_id.clone().unwrap();
let turn_index = u32::try_from(s.turns.len()).unwrap_or(u32::MAX);
let history = s.turns.clone();
let opts = kebab_app::AskOpts {
k: 0, // facade clamps to config.search.default_k floor
@@ -301,17 +401,27 @@ fn spawn_ask_worker(state: &mut App) {
temperature: None,
seed: None,
stream_sink: Some(tx),
// p9-fb-15: TUI ask is single-shot in this task; multi-turn
// conversation UI lands in p9-fb-16.
history: Vec::new(),
conversation_id: None,
turn_index: None,
history,
conversation_id: Some(conversation_id),
turn_index: Some(turn_index),
};
let handle =
thread::spawn(move || kebab_app::ask_with_config(cfg, &query, opts));
s.thread = Some(handle);
}
/// Generate a fresh conversation_id. Timestamp-based — unique per
/// session, not cryptographic. spec p9-fb-16 calls for blake3 of
/// (first_question + ts) but the only guarantee we need is
/// per-session uniqueness; nanosecond ts hex is enough.
fn make_conversation_id() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("conv_{:032x}", nanos)
}
/// Run-loop hook: drain the streaming channel into `partial`. Called
/// on every render frame so the answer area updates as tokens arrive.
pub(crate) fn drain_stream(state: &mut App) {
@@ -341,11 +451,20 @@ pub(crate) fn poll_worker(state: &mut App) {
s.rx = None;
match result {
Ok(Ok(answer)) => {
// Final partial is the full answer text; replace partial
// with the canonical answer.answer so post-stream rendering
// is identical regardless of stream pacing.
// p9-fb-16: graduate the in-flight (current_question +
// partial / answer) into a completed Turn appended to
// `turns`. Next submission's spawn_ask_worker reads
// `turns` as history and stamps turn_index.
let question = s.current_question.take().unwrap_or_default();
s.partial.clear();
s.answer = Some(answer);
let turn = kebab_core::Turn {
question,
answer: answer.answer.clone(),
citations: answer.citations.clone(),
created_at: answer.created_at,
};
s.turns.push(turn);
s.last_answer = Some(answer);
}
Ok(Err(e)) => {
s.last_error = Some(format!("{e:#}"));

View File

@@ -8,7 +8,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_config::Config;
use kebab_core::{
Answer, AnswerCitation, AnswerRetrievalSummary, Citation, ModelRef,
PromptTemplateVersion, RefusalReason, SearchMode, TokenUsage, TraceId, WorkspacePath,
PromptTemplateVersion, RefusalReason, SearchMode, TokenUsage, TraceId, Turn,
WorkspacePath,
};
use kebab_tui::{App, AskState, KeyOutcome, Pane, handle_key_ask, render_ask};
use ratatui::Terminal;
@@ -242,7 +243,17 @@ fn render_grounded_answer_with_citation() {
{
let s = app.ask.as_mut().unwrap();
s.input = "test".into();
s.answer = Some(make_answer(true, None, "test answer body [1]."));
let ans = make_answer(true, None, "test answer body [1].");
// p9-fb-16: transcript renders completed turns; populate one
// alongside last_answer so the right-panel status + body
// assertions both have something to find.
s.turns.push(Turn {
question: "test question".into(),
answer: ans.answer.clone(),
citations: ans.citations.clone(),
created_at: ans.created_at,
});
s.last_answer = Some(ans);
}
let backend = TestBackend::new(100, 24);
let mut terminal = Terminal::new(backend).unwrap();
@@ -274,7 +285,13 @@ fn render_refusal_score_gate_shows_status_without_citation_index_panic() {
let s = app.ask.as_mut().unwrap();
let mut ans = make_answer(false, Some(RefusalReason::ScoreGate), "insufficient grounding to answer.");
ans.citations.clear(); // refusal often has no citations
s.answer = Some(ans);
s.turns.push(Turn {
question: "test refusal question".into(),
answer: ans.answer.clone(),
citations: ans.citations.clone(),
created_at: ans.created_at,
});
s.last_answer = Some(ans);
}
let backend = TestBackend::new(120, 20);
let mut terminal = Terminal::new(backend).unwrap();
@@ -304,7 +321,7 @@ fn explain_toggle_changes_panel_title() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
s.answer = Some(make_answer(true, None, "answer body."));
s.last_answer = Some(make_answer(true, None, "answer body."));
s.explain = true;
}
let backend = TestBackend::new(100, 24);
@@ -378,3 +395,181 @@ fn no_ask_state_returns_to_library() {
);
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
}
// ── p9-fb-16: multi-turn conversation transcript ──────────────────────────
#[test]
fn ctrl_l_clears_conversation_state() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
s.conversation_id = Some("conv_test".into());
s.turns.push(Turn {
question: "Q".into(),
answer: "A".into(),
citations: Vec::new(),
created_at: OffsetDateTime::from_unix_timestamp(0).unwrap(),
});
s.last_answer = Some(make_answer(true, None, "A"));
s.partial = "leftover".into();
s.current_question = Some("in flight".into());
s.scroll = 5;
s.streaming = true;
// Note: thread / rx 는 JoinHandle 인 만큼 직접 mock 어려움 —
// streaming flag 만으로 detach side-effect 검증.
}
let outcome = handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL),
);
assert_eq!(outcome, KeyOutcome::Continue);
let s = app.ask.as_ref().unwrap();
assert!(s.turns.is_empty(), "turns cleared");
assert!(s.conversation_id.is_none(), "conversation_id cleared");
assert!(s.last_answer.is_none(), "last_answer cleared");
assert!(s.partial.is_empty(), "partial cleared");
assert!(s.current_question.is_none(), "current_question cleared");
assert_eq!(s.scroll, 0, "scroll reset");
// 회차 1 fix: streaming flag + thread/rx 도 detach.
assert!(!s.streaming, "streaming flag cleared");
assert!(s.thread.is_none(), "thread detached");
assert!(s.rx.is_none(), "rx detached");
}
#[test]
fn render_refusal_turn_in_transcript_uses_yellow_when_last_answer_ungrounded() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
let mut ans = make_answer(false, Some(RefusalReason::ScoreGate), "REFUSED BODY");
ans.citations.clear();
s.turns.push(Turn {
question: "Q".into(),
answer: ans.answer.clone(),
citations: Vec::new(),
created_at: ans.created_at,
});
s.last_answer = Some(ans);
}
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 24);
render_ask(f, area, &app);
})
.unwrap();
// Find the cell containing the first character of REFUSED BODY
// and assert its fg is Yellow (the refusal-style override).
let buffer = terminal.backend().buffer().clone();
let mut found = None;
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
let cell = &buffer[(x, y)];
if cell.symbol() == "R" {
// First R after Q: line — likely the answer body.
// Check fg color.
if let ratatui::style::Color::Yellow = cell.fg {
found = Some((x, y));
break;
}
}
}
if found.is_some() {
break;
}
}
assert!(
found.is_some(),
"expected at least one yellow R cell from REFUSED BODY in the transcript"
);
}
#[test]
fn render_transcript_shows_completed_turns_in_order() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
let ts = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
s.turns.push(Turn {
question: "first question".into(),
answer: "first answer".into(),
citations: Vec::new(),
created_at: ts,
});
s.turns.push(Turn {
question: "second question".into(),
answer: "second answer".into(),
citations: Vec::new(),
created_at: ts,
});
}
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 24);
render_ask(f, area, &app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let rendered: String = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Q1"), "Q1 marker rendered");
assert!(rendered.contains("Q2"), "Q2 marker rendered");
let q1_pos = rendered.find("Q1").unwrap();
let q2_pos = rendered.find("Q2").unwrap();
assert!(q1_pos < q2_pos, "chronological order: Q1 before Q2");
assert!(rendered.contains("first question"), "first question text");
assert!(rendered.contains("second answer"), "second answer text");
assert!(rendered.contains("transcript (2 turns)"), "title shows count");
}
#[test]
fn render_streaming_inflight_turn_appears_below_completed_turns() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
s.turns.push(Turn {
question: "first".into(),
answer: "ANSWERED".into(),
citations: Vec::new(),
created_at: OffsetDateTime::from_unix_timestamp(0).unwrap(),
});
s.streaming = true;
s.current_question = Some("follow-up".into());
s.partial = "PARTIAL".into();
}
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 24);
render_ask(f, area, &app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let rendered: String = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("ANSWERED"), "completed turn body");
assert!(rendered.contains("PARTIAL"), "in-flight partial body");
assert!(rendered.contains(""), "cursor block on in-flight turn");
let answered_pos = rendered.find("ANSWERED").unwrap();
let partial_pos = rendered.find("PARTIAL").unwrap();
assert!(
answered_pos < partial_pos,
"completed turn before in-flight; got: {answered_pos} vs {partial_pos}"
);
}

View File

@@ -3,7 +3,7 @@ phase: P9
component: kebab-tui (ask pane)
task_id: p9-fb-16
title: "TUI ask conversation transcript view"
status: planned
status: in_progress
depends_on: [p9-fb-15, p9-fb-12]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md