From 016f380428914c637a952defdd3e9b6025637325 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 25 May 2026 09:26:29 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(tui):=20fb-41=20PR-6=20=E2=80=94=20TUI?= =?UTF-8?q?=20Ask=20multi-hop=20toggle=20+=20hop=20trace=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/kebab-tui/src/app.rs | 8 ++ crates/kebab-tui/src/ask.rs | 48 ++++++- crates/kebab-tui/src/cheatsheet.rs | 1 + crates/kebab-tui/tests/ask.rs | 200 +++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 4 deletions(-) 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..269f9ea 100644 --- a/crates/kebab-tui/tests/ask.rs +++ b/crates/kebab-tui/tests/ask.rs @@ -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::>() + .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::() + }) + .collect::>() + .join("\n") +} -- 2.49.1 From b6756f8ce3a381a93b4acf5fc4e2fcfee11d3bea Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 25 May 2026 09:29:36 +0000 Subject: [PATCH 2/2] =?UTF-8?q?chore(tui):=20PR=20#173=20=ED=9A=8C?= =?UTF-8?q?=EC=B0=A8=201=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test `spawn_snapshot_multi_hop_into_askopts` → `ask_state_multi_hop_field_default_false_and_round_trips` 로 rename. 이전 이름은 spawn 동작 검증을 약속했으나 본문은 단순 field default + setter round-trip 만 검증 — name 과 실제 의도의 mismatch. 새 이름이 실제 검증 (field shape pin) 과 정확히 일치. doc string 도 spawn 동작은 별 path (live dogfood) 로 검증된다고 명확히 표기 — test 의 책임 범위가 무엇인지 reader 가 즉시 파악. 검증 - `cargo test -p kebab-tui -j 1 --test ask` — 42 test (6 multi-hop 포함) 모두 통과. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/tests/ask.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/kebab-tui/tests/ask.rs b/crates/kebab-tui/tests/ask.rs index 269f9ea..47e7ba4 100644 --- a/crates/kebab-tui/tests/ask.rs +++ b/crates/kebab-tui/tests/ask.rs @@ -1154,18 +1154,15 @@ fn status_panel_omits_hops_summary_for_single_pass() { ); } +/// 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 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. +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"); @@ -1175,6 +1172,7 @@ fn spawn_snapshot_multi_hop_into_askopts() { 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. -- 2.49.1