feat(tui): Ask conversation transcript UI (p9-fb-16)
Multi-turn ask pane. AskState 가 turns: Vec<Turn> + 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_<unix_nanos_hex>), 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 후보
|
||||
|
||||
|
||||
@@ -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 회귀 측정 |
|
||||
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<Line> = 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<Line<'static>>,
|
||||
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<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 +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<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 +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:#}"));
|
||||
|
||||
@@ -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::<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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user