diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index a5be019..8201780 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -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, + /// 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, } } } diff --git a/crates/kebab-tui/src/ask.rs b/crates/kebab-tui/src/ask.rs index d1cc5ad..f9c4707 100644 --- a/crates/kebab-tui/src/ask.rs +++ b/crates/kebab-tui/src/ask.rs @@ -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)); diff --git a/crates/kebab-tui/src/cheatsheet.rs b/crates/kebab-tui/src/cheatsheet.rs index f490ff9..49237ce 100644 --- a/crates/kebab-tui/src/cheatsheet.rs +++ b/crates/kebab-tui/src/cheatsheet.rs @@ -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)"), diff --git a/crates/kebab-tui/tests/ask.rs b/crates/kebab-tui/tests/ask.rs index c841a54..47e7ba4 100644 --- a/crates/kebab-tui/tests/ask.rs +++ b/crates/kebab-tui/tests/ask.rs @@ -988,3 +988,201 @@ 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::>() + .join("\n"); + assert!( + !body.contains("multi-hop:"), + "single-pass answer must NOT render the multi-hop summary line; got:\n{body}" + ); +} + +/// Light field-shape pin: the toggle exists, is bool, defaults +/// to false, and round-trips through the public `AskState` surface. +/// The actual spawn-time snapshot semantics (toggle value at Enter +/// is the value the worker sees) are guaranteed by the +/// `let multi_hop = s.multi_hop;` line at the top of +/// `spawn_ask_worker` — exercised in live multi-hop dogfood rather +/// than here (worker thread needs Ollama + a real KB). +#[test] +fn ask_state_multi_hop_field_default_false_and_round_trips() { + 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::() + }) + .collect::>() + .join("\n") +}