review(회차1): refusal yellow + Ctrl-L race fix

회차 1 actionable 2건 반영.

- (UX 회귀) push_turn_lines 가 answer_color_override: Option<Color>
  추가 받음. 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 00:02:43 +00:00
parent 7ea7264f5d
commit 6d5f39632f
2 changed files with 93 additions and 4 deletions

View File

@@ -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<Line> = 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<Color>,
) {
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, _) => {

View File

@@ -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]