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]