From 7ea7264f5df460f8b60b38c53ad632d69120512c Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 23:58:26 +0000 Subject: [PATCH 1/2] feat(tui): Ask conversation transcript UI (p9-fb-16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-turn ask pane. AskState 가 turns: Vec + current_question + conversation_id + last_answer 로 재설계. answer area 가 transcript 형식 (Q1/A1, Q2/A2, ...) 로 갈음, 매 Enter 가 이전 turns 를 history 로 worker 에 전달 — RagPipeline::ask_with_history 호출. 신규 (kebab-tui::app): - AskState 에 turns / current_question / conversation_id / last_answer 4 field 추가. 기존 answer field 제거 (last_answer 가 갈음). 신규 (kebab-tui::ask): - spawn_ask_worker: 첫 submit 시 conversation_id 자동 생성 (conv_), input → current_question, input clear. history = turns.clone(), turn_index = turns.len(). worker 가 ask_with_history 호출 (kebab-app facade 가 _cancellable 통해 RagPipeline::ask_with_history 까지 thread). - poll_worker: Answer 받으면 Turn { question: current_question, answer, citations, created_at } 만들어 turns 에 push, last_answer 도 보존. - handle_key_ask: Ctrl-L 가 turns + conversation_id 초기화 (in-flight worker 는 그대로 finish — 결과는 새 conversation 의 stale turn 으로 silently 폐기, 사용자 의도와 일치). - render_answer: 모든 completed turns + (있으면) in-flight turn chronological 출력. Q/A 라벨 색상 구분 (Q cyan bold, A green bold). in-flight answer 는 ▍ cursor + dim. transcript title 에 turn count. - render_status / render_citations_or_explain: s.last_answer 사용. Test: - 17 PASS (3 신규: ctrl_l_clears_conversation_state / render_transcript_shows_completed_turns_in_order / render_streaming_inflight_turn_appears_below_completed_turns). - 기존 14 회귀 0 (기존 s.answer → s.last_answer + Turn fixture push). README + HANDOFF: TUI 행에 multi-turn 동작 추가. spec status planned → in_progress. Co-Authored-By: Claude Opus 4.7 (1M context) --- HANDOFF.md | 1 + README.md | 2 +- crates/kebab-tui/src/app.rs | 37 ++++- crates/kebab-tui/src/ask.rs | 170 ++++++++++++++++------ crates/kebab-tui/tests/ask.rs | 147 ++++++++++++++++++- tasks/p9/p9-fb-16-tui-ask-conversation.md | 2 +- 6 files changed, 306 insertions(+), 53 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 5ffff69..563b320 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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>)` 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` 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` / `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` + `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_`). `Ctrl-L` 가 turns + conversation_id 초기화 (in-flight worker 는 그대로 finish, 결과는 새 conversation 의 stale turn 으로 silently 폐기). spec: `tasks/p9/p9-fb-16-tui-ask-conversation.md`. ## 다음 task 후보 diff --git a/README.md b/README.md index de176d8..80c09a7 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ kebab doctor | `kebab inspect doc ` / `kebab inspect chunk ` | raw record 보기 | | `kebab ask ""` | 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 회귀 측정 | diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index 7327e1f..117e2ba 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -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` 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, /// In-flight worker; `take()`n when it finishes. pub thread: Option>>, /// Token receiver paired with the worker's `Sender`. Drained /// every render frame. pub rx: Option>, - /// 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, + /// 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, + /// 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, + /// 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, + /// 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, } diff --git a/crates/kebab-tui/src/ask.rs b/crates/kebab-tui/src/ask.rs index 7081475..04fdc06 100644 --- a/crates/kebab-tui/src/ask.rs +++ b/crates/kebab-tui/src/ask.rs @@ -70,47 +70,87 @@ 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. + let mut lines: Vec = Vec::new(); + for (idx, turn) in s.turns.iter().enumerate() { + push_turn_lines(&mut lines, idx, &turn.question, &turn.answer, false); + 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); + } + + 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>, + idx: usize, + question: &str, + answer: &str, + streaming: bool, +) { + 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()), + ])); + let answer_style = 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 +164,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 = match &s.answer { + let lines: Vec = match &s.last_answer { None => vec![Line::from(Span::styled( "(no answer yet)", Style::default().add_modifier(Modifier::DIM), @@ -160,7 +200,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 = match &s.answer { + let lines: Vec = match &s.last_answer { None => vec![Line::from(Span::styled( "(submit a question to see citations)", Style::default().add_modifier(Modifier::DIM), @@ -200,6 +240,21 @@ 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; + KeyOutcome::Continue + } (KeyCode::Esc, _) => { // Best-effort cancellation per spec — worker keeps running // but its result is dropped. Detach by clearing rx / @@ -209,6 +264,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 +345,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 +368,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 +418,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:#}")); diff --git a/crates/kebab-tui/tests/ask.rs b/crates/kebab-tui/tests/ask.rs index 5d1d5c4..d768ef0 100644 --- a/crates/kebab-tui/tests/ask.rs +++ b/crates/kebab-tui/tests/ask.rs @@ -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,125 @@ 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; + } + 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"); +} + +#[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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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}" + ); +} diff --git a/tasks/p9/p9-fb-16-tui-ask-conversation.md b/tasks/p9/p9-fb-16-tui-ask-conversation.md index 26ed901..9a99f00 100644 --- a/tasks/p9/p9-fb-16-tui-ask-conversation.md +++ b/tasks/p9/p9-fb-16-tui-ask-conversation.md @@ -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 From 6d5f39632fbaf4abc3d72bbb4868454b492c53f1 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 3 May 2026 00:02:43 +0000 Subject: [PATCH 2/2] =?UTF-8?q?review(=ED=9A=8C=EC=B0=A81):=20refusal=20ye?= =?UTF-8?q?llow=20+=20Ctrl-L=20race=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회차 1 actionable 2건 반영. - (UX 회귀) push_turn_lines 가 answer_color_override: Option 추가 받음. render_answer 가 마지막 turn 에 한해 last_answer.grounded == false 면 Yellow override 전달 → P9-3 의 refusal 시각 구분 contract 가 transcript 안에서도 보존. test: render_refusal_turn_in_transcript_uses_yellow_when_last_answer_ungrounded 가 buffer 의 Yellow R 셀 검사로 검증. - (race) Ctrl-L 가 turns/conversation_id/last_answer/partial/ current_question/scroll 외에도 thread/rx/streaming 까지 detach. in-flight worker 가 다음 frame 에 finish 해도 새 conv 의 stale Turn 으로 graduate 안 됨 — JoinHandle Drop 으로 detach (P9-3 Esc cancel pattern 동일). worker 자체는 background 에서 SQLite answers 에 \"실패한 conv\" 흔적 commit. ctrl_l_clears_conversation_state test 가 streaming/thread/rx 도 함께 검증. 18 PASS. clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/src/ask.rs | 41 ++++++++++++++++++++++--- crates/kebab-tui/tests/ask.rs | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/crates/kebab-tui/src/ask.rs b/crates/kebab-tui/src/ask.rs index 04fdc06..60220bb 100644 --- a/crates/kebab-tui/src/ask.rs +++ b/crates/kebab-tui/src/ask.rs @@ -86,10 +86,27 @@ fn render_answer(f: &mut Frame, area: Rect, s: &AskState) { // 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. + // 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 = Vec::new(); for (idx, turn) in s.turns.iter().enumerate() { - push_turn_lines(&mut lines, idx, &turn.question, &turn.answer, false); + 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("")); } @@ -98,7 +115,7 @@ fn render_answer(f: &mut Frame, area: Rect, s: &AskState) { let mut a = s.partial.clone(); a.push('▍'); let idx = s.turns.len(); - push_turn_lines(&mut lines, idx, q, &a, true); + push_turn_lines(&mut lines, idx, q, &a, true, None); } if lines.is_empty() { @@ -123,6 +140,7 @@ fn push_turn_lines( question: &str, answer: &str, streaming: bool, + answer_color_override: Option, ) { let q_label = format!("Q{}", idx + 1); let a_label = format!("A{}", idx + 1); @@ -136,7 +154,12 @@ fn push_turn_lines( Span::raw(": "), Span::raw(question.to_string()), ])); - let answer_style = if streaming { + // 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() @@ -253,6 +276,16 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome { 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, _) => { diff --git a/crates/kebab-tui/tests/ask.rs b/crates/kebab-tui/tests/ask.rs index d768ef0..cf40455 100644 --- a/crates/kebab-tui/tests/ask.rs +++ b/crates/kebab-tui/tests/ask.rs @@ -414,6 +414,9 @@ fn ctrl_l_clears_conversation_state() { 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, @@ -427,6 +430,59 @@ fn ctrl_l_clears_conversation_state() { 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]