feat(tui): fb-41 PR-6 — TUI Ask multi-hop toggle + hop trace summary
fb-41 multi-hop RAG 의 **마지막 component PR** (PR-5 머지 직후). TUI Ask
패널의 user-facing surface — F2 toggle, multi-hop badge, status panel
의 hop count summary, cheatsheet 안내. v0.18.0 cut 준비.
설계: docs/superpowers/specs/2026-05-25-p9-fb-41-multi-hop-rag-design.md
계획: docs/superpowers/plans/2026-05-25-p9-fb-41-multi-hop-rag.md (PR-6 단락)
## TUI surface
- `crates/kebab-tui/src/app.rs`:
- `AskState.multi_hop: bool` field + Default false. 사용자 토글
상태를 인-패널 보존, 대화 history 와 직교 — F2 flipping mid-
conversation 도 turns 보존 (다음 turn 만 다른 pipeline 으로 route).
- `crates/kebab-tui/src/ask.rs`:
- `handle_key_ask` 에 `(KeyCode::F(2), _) → s.multi_hop = !s.multi_hop`.
Mode-agnostic (physical function key — Normal/Insert 양쪽 작동, typing
ambiguity 없음). Briefing 의 candidate (F2 vs Ctrl-T) 중 F2 채택 —
Ctrl-M 은 Enter 와 collision 이미 명시, F2 가 cleanest.
- `spawn_ask_worker` 의 `AskOpts.multi_hop` 가 spawn 시점에 토글값
snapshot. 이후 F2 flip 은 다음 Enter 부터 적용 (in-flight turn 무영향).
- `render_input` 의 input pane title 에 `F2=multi-hop` binding 안내
추가 + prompt row 에 `multi-hop` badge (Success 녹색, toggled-on 일
때만). 사용자가 어떤 pipeline 으로 다음 query 를 보낼지 항상 가시.
- `render_status` 의 status panel 에 `multi-hop: N hops` line 추가
(last_answer.hops 가 Some 일 때만). forced_stop 발생 시
`forced_stop=K` suffix — depth/pool cap tuning 단서.
- `crates/kebab-tui/src/cheatsheet.rs`:
- Ask section 에 `F2 toggle multi-hop pipeline` entry 추가.
## 변경 없음 (의도된 deferral)
- `InspectTarget::Hop(turn_index)` variant — plan 의 PR-6 stretch goal.
per-iter hop trace detail 을 Inspect 패널에 노출하는 기능은 별 PR
(PR-6b 또는 v0.18 dogfood follow-up). PR-6 의 핵심 가치
(사용자가 multi-hop pipeline 을 토글하고 결과의 hop count 를 본다)
는 status panel 의 한 줄 summary 로 100% cover. Inspect 진입은
multi-hop 사용자가 *드물게* 필요한 surface — v0.18 cut 부담 회피.
- prompt_template_version (`rag-multi-hop-v1`) — 그대로.
- MCP / CLI surface — PR-4 / PR-5 의 책임.
## Tests (`tests/ask.rs` 신규 6 multi-hop pins)
- `f2_toggles_multi_hop_flag_from_insert_mode`: Insert 에서 F2 toggle
(fresh_app default mode).
- `f2_toggles_multi_hop_flag_from_normal_mode`: Normal 에서도 동일
— mode-agnostic 회귀 핀.
- `input_pane_shows_multi_hop_badge_when_toggled_on`: 토글 on 시
prompt row 에 `multi-hop` 등장 + title 의 `F2=multi-hop` binding
hint 등장.
- `input_pane_omits_multi_hop_badge_when_toggled_off`: 토글 off 시
prompt row 의 badge 부재 (title hint 는 유지 — 사용자 discoverability).
- `status_panel_summarizes_hops_when_answer_has_trace`: 3-hop trace
(Decompose + Decide + Synthesize) → `multi-hop: 3 hops` line.
- `status_panel_omits_hops_summary_for_single_pass`: hops=None → 본문
에 summary line 부재 (title binding hint 만).
- `spawn_snapshot_multi_hop_into_askopts`: AskState.multi_hop 의
field shape 회귀 핀 (default false / settable / round-trip).
## 검증
- `cargo test -p kebab-tui -j 1` — 신규 6 multi-hop + 기존 ask /
search / library / mode / cheatsheet / inspect / status_bar 모두
통과 (42 ask test + 10 mode + 기타). 회귀 없음.
- `cargo clippy -p kebab-tui --all-targets -j 1 -- -D warnings` clean.
- 단일 crate 직렬 build (16 GB RAM 제약).
## v0.18.0 cut (다음 단계)
- Workspace `Cargo.toml` version 0.17.2 → 0.18.0 (minor — surface
확장 + new prompt_template_version `rag-multi-hop-v1`).
- HANDOFF.md / HOTFIXES.md / INDEX.md 갱신 (fb-41 entry 정리).
- `gitea-release v0.18.0 --auto-notes`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -257,6 +257,13 @@ pub struct AskState {
|
||||
/// `Turn`; this slot is just the easiest place for the panel
|
||||
/// renderer to look.
|
||||
pub last_answer: Option<kebab_core::Answer>,
|
||||
/// p9-fb-41: toggle for the multi-hop pipeline. `F2` flips it
|
||||
/// from the Ask pane; the next `Enter` snapshot picks the value
|
||||
/// into `AskOpts.multi_hop` before spawning the worker. Default
|
||||
/// `false` (single-pass). Conversation history (`turns`) survives
|
||||
/// the toggle — flipping mid-conversation just changes the
|
||||
/// pipeline used for the *next* turn.
|
||||
pub multi_hop: bool,
|
||||
}
|
||||
|
||||
impl Default for AskState {
|
||||
@@ -277,6 +284,7 @@ impl Default for AskState {
|
||||
current_question: None,
|
||||
conversation_id: None,
|
||||
last_answer: None,
|
||||
multi_hop: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,11 @@ fn render_input(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::T
|
||||
const PROMPT: &str = "? ";
|
||||
|
||||
let mode_badge = if s.explain { " explain" } else { "" };
|
||||
// p9-fb-41: visible badge for the multi-hop toggle so the user
|
||||
// always knows which pipeline the next submission will use.
|
||||
// Styled with `Success` (multi_hop=on) so it stands out from
|
||||
// the `explain` warning-colored badge.
|
||||
let multi_hop_badge = if s.multi_hop { " multi-hop" } else { "" };
|
||||
// Distinguish three async states for the operator:
|
||||
// - currently streaming (worker still emitting tokens)
|
||||
// - prior worker detached (Esc-cancelled, no rx attached but
|
||||
@@ -69,10 +74,11 @@ fn render_input(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::T
|
||||
Span::styled(PROMPT, theme.style(crate::theme::Role::Heading)),
|
||||
Span::raw(s.input.as_str()),
|
||||
Span::styled(mode_badge, theme.style(crate::theme::Role::Warning)),
|
||||
Span::styled(multi_hop_badge, theme.style(crate::theme::Role::Success)),
|
||||
Span::styled(busy, theme.style(crate::theme::Role::Hint)),
|
||||
]);
|
||||
let block = Block::default()
|
||||
.title("ask (Enter=submit e=explain Ctrl-L=new conversation Esc=back)")
|
||||
.title("ask (Enter=submit e=explain F2=multi-hop Ctrl-L=new conversation Esc=back)")
|
||||
.borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
let paragraph = Paragraph::new(line).block(block);
|
||||
@@ -257,14 +263,33 @@ fn render_status(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::
|
||||
}
|
||||
None => "",
|
||||
};
|
||||
vec![
|
||||
let mut lines = vec![
|
||||
Line::from(format!("grounded {grounded} model {}", a.model.id)),
|
||||
Line::from(format!("prompt {} mode {mode}", a.prompt_template_version.0)),
|
||||
Line::from(format!(
|
||||
"k={} used={}/{}{refusal}",
|
||||
a.retrieval.k, a.retrieval.chunks_used, a.retrieval.chunks_returned
|
||||
)),
|
||||
]
|
||||
];
|
||||
// p9-fb-41: surface a brief multi-hop summary when the
|
||||
// turn was routed through the multi-hop pipeline. The
|
||||
// full per-hop trace lives in `Answer.hops`; this line
|
||||
// is the at-a-glance "yes, this used N hops" signal.
|
||||
// `forced_stop` count flags depth/pool-cap terminations
|
||||
// — useful for tuning `multi_hop_max_depth` etc.
|
||||
if let Some(hops) = a.hops.as_ref() {
|
||||
let forced = hops.iter().filter(|h| h.forced_stop).count();
|
||||
let forced_tag = if forced > 0 {
|
||||
format!(" forced_stop={forced}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("multi-hop: {} hops{forced_tag}", hops.len()),
|
||||
theme.style(crate::theme::Role::Success),
|
||||
)));
|
||||
}
|
||||
lines
|
||||
}
|
||||
};
|
||||
f.render_widget(Paragraph::new(lines).block(block), area);
|
||||
@@ -418,6 +443,18 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome {
|
||||
s.scroll = 0;
|
||||
KeyOutcome::Continue
|
||||
}
|
||||
// p9-fb-41: F2 toggles multi-hop. Mode-agnostic (physical
|
||||
// function key, no typing ambiguity). The toggle takes
|
||||
// effect on the *next* Enter submission — the in-flight
|
||||
// turn (if any) keeps the multi_hop value it was spawned
|
||||
// with. Conversation history (`turns`) survives the flip;
|
||||
// a follow-up turn just routes through the other pipeline
|
||||
// (no silent invalidation per p9-fb-16's contract).
|
||||
(KeyCode::F(2), _) => {
|
||||
let s = state.ask.as_mut().unwrap();
|
||||
s.multi_hop = !s.multi_hop;
|
||||
KeyOutcome::Continue
|
||||
}
|
||||
(KeyCode::Backspace, _) => {
|
||||
let s = state.ask.as_mut().unwrap();
|
||||
s.input.pop_char();
|
||||
@@ -493,6 +530,9 @@ fn spawn_ask_worker(state: &mut App) {
|
||||
// clear). The buffer is left empty with cursor at 0.
|
||||
let query = s.input.take();
|
||||
let explain = s.explain;
|
||||
// p9-fb-41: snapshot the toggle at spawn time. Later F2 flips
|
||||
// do NOT affect the in-flight turn.
|
||||
let multi_hop = s.multi_hop;
|
||||
s.partial.clear();
|
||||
s.last_answer = None;
|
||||
s.streaming = true;
|
||||
@@ -522,7 +562,7 @@ fn spawn_ask_worker(state: &mut App) {
|
||||
history,
|
||||
conversation_id: Some(conversation_id),
|
||||
turn_index: Some(turn_index),
|
||||
multi_hop: false,
|
||||
multi_hop,
|
||||
};
|
||||
let handle =
|
||||
thread::spawn(move || kebab_app::ask_with_config(cfg, &query, opts));
|
||||
|
||||
@@ -89,6 +89,7 @@ pub fn render_cheatsheet(f: &mut Frame, area: Rect, app: &App) {
|
||||
("type", "question (Insert)"),
|
||||
("Enter", "submit"),
|
||||
("e", "toggle explain mode (Normal)"),
|
||||
("F2", "toggle multi-hop pipeline (p9-fb-41 — affects next submission)"),
|
||||
("j / k", "scroll transcript (Normal — disengages auto-tail)"),
|
||||
("Shift-G", "jump to bottom + re-engage auto-tail (p9-fb-22)"),
|
||||
("PgUp / PgDn", "page-scroll the transcript (p9-fb-24, disengages auto-tail)"),
|
||||
|
||||
@@ -988,3 +988,203 @@ fn follow_tail_renders_tail_when_transcript_overflows() {
|
||||
"tail of transcript must be visible when follow_tail is on; got:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── p9-fb-41: multi-hop toggle ───────────────────────────────────────────
|
||||
|
||||
/// `F2` flips `AskState.multi_hop` from any mode (Normal or Insert)
|
||||
/// — it's a physical function key, not a Char, so the mode gating
|
||||
/// in handle_key_ask doesn't apply.
|
||||
#[test]
|
||||
fn f2_toggles_multi_hop_flag_from_insert_mode() {
|
||||
let mut app = fresh_app();
|
||||
// fresh_app sets Insert mode on the Ask pane (auto-flip).
|
||||
assert_eq!(app.mode, kebab_tui::Mode::Insert);
|
||||
assert!(!app.ask.as_ref().unwrap().multi_hop, "default off");
|
||||
|
||||
handle_key_ask(&mut app, KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
|
||||
assert!(
|
||||
app.ask.as_ref().unwrap().multi_hop,
|
||||
"first F2 turns multi-hop on"
|
||||
);
|
||||
|
||||
handle_key_ask(&mut app, KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
|
||||
assert!(
|
||||
!app.ask.as_ref().unwrap().multi_hop,
|
||||
"second F2 turns it back off"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f2_toggles_multi_hop_flag_from_normal_mode() {
|
||||
let mut app = fresh_app();
|
||||
app.mode = kebab_tui::Mode::Normal;
|
||||
assert!(!app.ask.as_ref().unwrap().multi_hop);
|
||||
|
||||
handle_key_ask(&mut app, KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
|
||||
assert!(
|
||||
app.ask.as_ref().unwrap().multi_hop,
|
||||
"F2 in Normal mode must also toggle multi-hop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_pane_shows_multi_hop_badge_when_toggled_on() {
|
||||
let mut app = fresh_app();
|
||||
app.ask.as_mut().unwrap().multi_hop = true;
|
||||
|
||||
let backend = TestBackend::new(80, 20);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| render_ask(f, Rect::new(0, 0, 80, 20), &app))
|
||||
.unwrap();
|
||||
let rendered = render_to_string(terminal.backend().buffer());
|
||||
assert!(
|
||||
rendered.contains("multi-hop"),
|
||||
"input pane must surface a multi-hop badge when toggled on; got:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("F2=multi-hop"),
|
||||
"ask input title must advertise the F2 binding; got:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_pane_omits_multi_hop_badge_when_toggled_off() {
|
||||
let app = fresh_app();
|
||||
assert!(!app.ask.as_ref().unwrap().multi_hop);
|
||||
|
||||
let backend = TestBackend::new(80, 20);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| render_ask(f, Rect::new(0, 0, 80, 20), &app))
|
||||
.unwrap();
|
||||
let rendered = render_to_string(terminal.backend().buffer());
|
||||
// The title still advertises the binding (so users discover the
|
||||
// feature) but the *badge* text "multi-hop" must NOT appear next
|
||||
// to the prompt — the line is the toggle-state signal.
|
||||
//
|
||||
// We can't simply assert `!rendered.contains("multi-hop")` because
|
||||
// the title itself contains the word. Instead split on the input
|
||||
// prompt and confirm the badge segment of the input line is absent.
|
||||
// Match the layout: the input pane is the first row, title on the
|
||||
// border, prompt + badge on the inner row.
|
||||
assert!(
|
||||
rendered.contains("F2=multi-hop"),
|
||||
"title binding hint must always be visible; got:\n{rendered}"
|
||||
);
|
||||
let prompt_row = rendered.lines().find(|l| l.contains("?")).unwrap_or("");
|
||||
assert!(
|
||||
!prompt_row.contains("multi-hop"),
|
||||
"the badge belongs on the prompt row only when toggled on; got row:\n{prompt_row}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_panel_summarizes_hops_when_answer_has_trace() {
|
||||
use kebab_core::{HopKind, HopRecord};
|
||||
|
||||
let mut app = fresh_app();
|
||||
let mut answer = make_answer(true, None, "compound answer [#1]");
|
||||
answer.hops = Some(vec![
|
||||
HopRecord {
|
||||
iter: 0,
|
||||
kind: HopKind::Decompose,
|
||||
sub_queries: vec!["q1".into(), "q2".into()],
|
||||
context_chunks_added: 0,
|
||||
forced_stop: false,
|
||||
llm_call_ms: 7,
|
||||
},
|
||||
HopRecord {
|
||||
iter: 1,
|
||||
kind: HopKind::Decide,
|
||||
sub_queries: vec![],
|
||||
context_chunks_added: 3,
|
||||
forced_stop: false,
|
||||
llm_call_ms: 5,
|
||||
},
|
||||
HopRecord {
|
||||
iter: 2,
|
||||
kind: HopKind::Synthesize,
|
||||
sub_queries: vec![],
|
||||
context_chunks_added: 0,
|
||||
forced_stop: false,
|
||||
llm_call_ms: 11,
|
||||
},
|
||||
]);
|
||||
app.ask.as_mut().unwrap().last_answer = Some(answer);
|
||||
|
||||
let backend = TestBackend::new(80, 20);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| render_ask(f, Rect::new(0, 0, 80, 20), &app))
|
||||
.unwrap();
|
||||
let rendered = render_to_string(terminal.backend().buffer());
|
||||
assert!(
|
||||
rendered.contains("multi-hop: 3 hops"),
|
||||
"status panel must surface the hop count; got:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_panel_omits_hops_summary_for_single_pass() {
|
||||
let mut app = fresh_app();
|
||||
let mut answer = make_answer(true, None, "single-pass answer [#1]");
|
||||
answer.hops = None;
|
||||
app.ask.as_mut().unwrap().last_answer = Some(answer);
|
||||
|
||||
let backend = TestBackend::new(80, 20);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| render_ask(f, Rect::new(0, 0, 80, 20), &app))
|
||||
.unwrap();
|
||||
let rendered = render_to_string(terminal.backend().buffer());
|
||||
// The status panel renders 3 lines (grounded / prompt / k/used)
|
||||
// for single-pass — no "multi-hop:" line. Only the *title*
|
||||
// binding hint ("F2=multi-hop") may contain the substring.
|
||||
// Filter that row out, then assert the remaining buffer has no
|
||||
// hops summary.
|
||||
let body: String = rendered
|
||||
.lines()
|
||||
.filter(|l| !l.contains("F2=multi-hop"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
!body.contains("multi-hop:"),
|
||||
"single-pass answer must NOT render the multi-hop summary line; got:\n{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_snapshot_multi_hop_into_askopts() {
|
||||
// We can't actually spawn the worker (no Ollama / KB), but we
|
||||
// can pin the snapshot semantics: the toggle's value at the
|
||||
// moment of Enter is the value the worker sees. Flipping F2
|
||||
// after Enter doesn't retro-affect the in-flight turn.
|
||||
//
|
||||
// Strategy: read the toggle directly, then verify the contract
|
||||
// by inspecting `AskState.multi_hop` after a flip — the
|
||||
// toggle's "next-turn" semantics are documented but we keep
|
||||
// the regression light: the field exists, is bool, defaults
|
||||
// to false, and round-trips through the public surface.
|
||||
let mut app = fresh_app();
|
||||
let s = app.ask.as_mut().unwrap();
|
||||
assert!(!s.multi_hop, "default false");
|
||||
s.multi_hop = true;
|
||||
assert!(s.multi_hop, "settable to true");
|
||||
s.multi_hop = false;
|
||||
assert!(!s.multi_hop, "settable back to false");
|
||||
}
|
||||
|
||||
/// Small render helper shared with the rest of the test module's
|
||||
/// buffer-snapshot pattern. We define it locally here to avoid
|
||||
/// reaching into private internals.
|
||||
fn render_to_string(buffer: &ratatui::buffer::Buffer) -> String {
|
||||
(0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user