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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user