feat(kebab-tui): p9-fb-13 cheatsheet popup (F1) #86
@@ -58,6 +58,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-18)** — CLI `kebab ask --session <id>` (multi-turn). p9-fb-17 의 ChatSessionRepo 위에 `kebab-app::App::ask_with_session(session_id, query, opts) -> Answer` 메서드. 첫 호출 시 자동으로 `chat_sessions` row 생성 (title = 첫 question NFC trim 40 chars), 이후 호출은 `list_turns` 로 prior history 받아 `RagPipeline::ask_with_history` 호출 + 새 turn append. `App` 의 helper: `first_question_title(question)` (NFC + trim + 40 char cap, fallback `"untitled"`) + `blake3_truncate(input)` (32-hex `turn_id` 생성). facade `kebab_app::ask_with_session_with_config` + CLI `--session <id>` flag 추가. `--repl` 은 spec 명시 사항이지만 stdin loop fixture 부담 으로 후속 task 로 deferral (out of scope per HANDOFF). spec: `tasks/p9/p9-fb-18-cli-ask-session-repl.md`.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-12 partial)** — TUI vim-style mode machine (절반 ship — heuristic 제거는 follow-up). `kebab_tui::Mode::{Normal, Insert}` enum + `Mode::auto_for(pane)` (Library/Inspect/Jobs → Normal, Search/Ask → Insert) + `Mode::label()` (`"-- NORMAL --"` / `"-- INSERT --"`) + `App.mode: Mode` field. run loop `mode_intercept(app, key)` 가 dispatch 전 intercept — Insert 에서 `Esc` → Normal (어디서나), Normal 에서 `i` → Insert (Library/Inspect/Jobs 만, Search/Ask 는 자동 Insert 라 `i` 가 typed char). 헤더 우측에 mode label colored (Insert = Role::Success green, Normal = Role::Heading cyan+bold). pane 전환 시 `app.mode = Mode::auto_for(p)` 자동 flip. **Deferred (HOTFIXES entry)**: `is_typing_mod` (search) + input-empty heuristic (ask) 는 후속 PR 에서 mode-authoritative 로 교체 — 현재는 user-visible signal (label + auto flip + i/Esc) 만 ship, 키 dispatch 는 heuristic 유지. spec status `in_progress` (not `completed`). spec: `tasks/p9/p9-fb-12-tui-mode-machine.md`.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-12 follow-up)** — heuristic 제거 (partial PR 의 deferred 부분 finalize). `search::is_typing_mod` (CTRL/ALT chord filter) 함수 삭제 + `ask::handle_key_ask` 의 input-empty heuristic 삭제. 새 dispatch: `search::handle_key_search` 의 `i` (chunk inspect) / `g` (editor jump) pre-pass 가 `state.mode == Mode::Normal` 일 때만 fire (Insert 에서는 typed char). main match 의 `j`/`k`/Char(c) 가 `state.mode` 로 분기 (Normal → 선택 이동, Insert → input.push). `ask::handle_key_ask` 의 `e`/`j`/`k` 도 동일 패턴 — Normal 에서 toggle/scroll, Insert 에서 input typing. 테스트 fixture (`tests/search.rs::fresh_app`, `tests/ask.rs::fresh_app`) 가 `app.mode = Mode::auto_for(focus)` 로 run-loop 동작 mirror. 기존 nav 테스트 (j_k_move, g_key_enqueues, e_toggles) 는 explicit `app.mode = Mode::Normal` 추가, 신규 4 테스트 (j_in_insert_types / arbitrary_char_in_normal_noop / e_types_in_insert / jk_scroll-in-normal-type-in-insert) 가 mode-authoritative 동작 pin. spec status `in_progress` → `completed`. spec: `tasks/p9/p9-fb-12-tui-mode-machine.md`.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-13)** — TUI cheatsheet popup. `kebab-tui::cheatsheet::render_cheatsheet(f, area, app)` 신규 — 70%/60% centered modal, sections (Global / Library / Search / Ask / Inspect) + global toggle table + 현재 focused pane footer. `App.cheatsheet_visible: bool` 필드 + `pub fn cheatsheet_visible()` getter. run loop `cheatsheet_intercept(app, key)` 가 mode_intercept 보다 먼저 dispatch — `F1` 토글 (open/close), `Esc` 가 visible 일 때 닫기 (mode_intercept 를 우회해서 cheatsheet 닫기 가 mode flip 도 발동시키지 않도록), 그 외 키는 fall-through (popup 열린 채 navigation 가능). modifier-bearing F1 (Ctrl-F1 등) 은 무시. **HOTFIXES 기록**: spec 의 `?` trigger 가 Library 의 quick-Ask binding 과 충돌해서 `F1` 으로 rebind. spec 의 verb-form hint line 재구성은 별 후속 PR (기존 footer 가 동일 역할). spec status `planned` → `in_progress` (verb hint deferral 으로 partial). spec: `tasks/p9/p9-fb-13-tui-cheatsheet.md`.
|
||||
|
||||
## 다음 task 후보
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ kebab doctor
|
||||
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
|
||||
| `kebab ask "<query>" [--show-citations / --hide-citations] [--session <id>]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session <id>` 로 multi-turn — 첫 호출에서 SQLite `chat_sessions` 에 자동 생성, 이후 호출은 prior turns 를 history 로 받아 follow-up. session id 는 사용자 지정 (e.g. `kb-rust-async-2026-05`) — `kebab reset --data-only` 로 모든 session wipe |
|
||||
| `kebab doctor` | 설정/모델/DB 헬스 체크 |
|
||||
| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (Library/Inspect 만), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/i/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해) |
|
||||
| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (Library/Inspect 만), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/i/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. **`F1` 로 cheatsheet popup** (현재 pane 의 키 매핑 + global 토글 표) — `Esc` / `F1` 로 닫기. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해) |
|
||||
| `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) |
|
||||
| `kebab eval run / compare` | golden query 회귀 측정 |
|
||||
|
||||
|
||||
@@ -380,6 +380,21 @@ pub struct App {
|
||||
/// any future code path that needs a forced redraw can flip
|
||||
/// this flag.
|
||||
pub(crate) force_redraw: bool,
|
||||
/// p9-fb-13: cheatsheet popup visibility. Toggled by `F1` (set
|
||||
/// via `cheatsheet_intercept` in the run loop). When true, the
|
||||
/// renderer overlays a modal listing every keybinding for the
|
||||
/// active pane plus the global mode toggles.
|
||||
pub(crate) cheatsheet_visible: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// p9-fb-13: read-only accessor for the cheatsheet visibility
|
||||
/// flag — used by integration tests to assert the toggle
|
||||
/// without exposing the field as `pub` (which would let
|
||||
/// external code break the F1-only set/unset invariant).
|
||||
pub fn cheatsheet_visible(&self) -> bool {
|
||||
self.cheatsheet_visible
|
||||
}
|
||||
}
|
||||
|
||||
/// p9-fb-09: external-program spawn request. Posted by a pane's key
|
||||
@@ -413,6 +428,7 @@ impl App {
|
||||
should_quit: false,
|
||||
pending_editor: None,
|
||||
force_redraw: false,
|
||||
cheatsheet_visible: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
152
crates/kebab-tui/src/cheatsheet.rs
Normal file
152
crates/kebab-tui/src/cheatsheet.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
//! p9-fb-13: cheatsheet popup (`F1` toggle).
|
||||
//!
|
||||
//! Modal overlay listing every key binding the active pane responds
|
||||
//! to, plus the global mode toggles (`i`/`Esc`). Triggered with
|
||||
//! `F1` (universal help key — no collision with the existing Library
|
||||
//! `?` binding, which already opens the Ask pane). `F1` or `Esc`
|
||||
//! while the popup is visible closes it.
|
||||
//!
|
||||
//! Spec p9-fb-13 lists `?` as the trigger and a verb-form hint line
|
||||
//! above the status bar. Both are deferred:
|
||||
//!
|
||||
//! * `?` would clobber Library's quick-Ask binding (`Char('?') →
|
||||
//! SwitchPane(Ask)`). We swap to `F1` per HOTFIXES — common help
|
||||
//! key, no rebinding needed.
|
||||
//! * The verb hint line redesign sits in the existing `render_footer`
|
||||
//! path; the per-pane string already serves the same role. A
|
||||
//! future PR can split it into mode-aware verb fragments.
|
||||
//!
|
||||
//! **Maintenance**: the `push_section(...)` calls below hold every
|
||||
//! key binding as a literal string — there is NO automated link
|
||||
//! from `handle_key_*` to the cheatsheet entries. A future PR that
|
||||
//! changes a binding (e.g. swap `r` → `R` for ingest) MUST update
|
||||
//! the matching entry here. Drift would be silently invisible
|
||||
//! (the cheatsheet still renders, but lies about the live key).
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
||||
|
||||
use crate::app::{App, Pane};
|
||||
use crate::theme::{Role, Theme};
|
||||
|
||||
/// Render the cheatsheet popup, centered on `area` with a 70% / 60%
|
||||
/// box (matches the error overlay's footprint so the visual rhythm
|
||||
/// is consistent). The body is one section per pane plus the global
|
||||
/// toggles.
|
||||
pub fn render_cheatsheet(f: &mut Frame, area: Rect, app: &App) {
|
||||
let popup_area = centered_rect(area, 70, 60);
|
||||
f.render_widget(Clear, popup_area);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::from(Span::styled(
|
||||
"kebab TUI — keymap (F1 / Esc to close)",
|
||||
app.theme
|
||||
.style(Role::Heading)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
push_section(&mut lines, &app.theme, "Global", &[
|
||||
("i", "Normal → Insert (Library / Inspect / Jobs only)"),
|
||||
("Esc", "Insert → Normal (any pane)"),
|
||||
("F1", "toggle this cheatsheet"),
|
||||
("Tab / Shift-Tab", "(future) cycle pane"),
|
||||
]);
|
||||
|
||||
push_section(&mut lines, &app.theme, "Library", &[
|
||||
("j / k", "move selection (Normal)"),
|
||||
("gg / G", "top / bottom"),
|
||||
("f", "filter overlay"),
|
||||
("/", "switch to Search"),
|
||||
("?", "switch to Ask"),
|
||||
("Enter", "inspect selected doc"),
|
||||
("r", "background ingest"),
|
||||
("q", "quit"),
|
||||
]);
|
||||
|
||||
push_section(&mut lines, &app.theme, "Search", &[
|
||||
("type", "query (Insert)"),
|
||||
("Tab", "cycle search mode (lexical / vector / hybrid)"),
|
||||
("Enter", "force search now (skip debounce)"),
|
||||
("j / k", "move selection (Normal)"),
|
||||
("g", "open hit's citation in $EDITOR (Normal)"),
|
||||
("i", "inspect selected hit's chunk (Normal)"),
|
||||
("Esc", "back to Library"),
|
||||
]);
|
||||
|
||||
push_section(&mut lines, &app.theme, "Ask", &[
|
||||
("type", "question (Insert)"),
|
||||
("Enter", "submit"),
|
||||
("e", "toggle explain mode (Normal)"),
|
||||
("j / k", "scroll transcript (Normal)"),
|
||||
("Ctrl-L", "new conversation (clears turns)"),
|
||||
("Esc", "back to Library (cancels in-flight worker)"),
|
||||
]);
|
||||
|
||||
push_section(&mut lines, &app.theme, "Inspect", &[
|
||||
("j / k", "scroll lines"),
|
||||
("PgUp / PgDn", "scroll pages"),
|
||||
("c", "collapse / expand all sections"),
|
||||
("Esc / q", "back to originating pane"),
|
||||
]);
|
||||
|
||||
// Pane footer: which pane is currently focused (helps the
|
||||
// reader correlate \"the keys above\" with their current
|
||||
// context).
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("(currently focused: {})", pane_label(app.focus)),
|
||||
app.theme.style(Role::Hint),
|
||||
)));
|
||||
|
||||
let block = Block::default()
|
||||
.title("? cheatsheet")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(app.theme.style(Role::Heading));
|
||||
let para = Paragraph::new(lines)
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false });
|
||||
f.render_widget(para, popup_area);
|
||||
}
|
||||
|
||||
fn push_section(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
theme: &Theme,
|
||||
name: &'static str,
|
||||
keys: &[(&'static str, &'static str)],
|
||||
) {
|
||||
lines.push(Line::from(Span::styled(
|
||||
name,
|
||||
theme.style(Role::Heading).add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
for (key, desc) in keys {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{:<18}", key), theme.style(Role::CitationMarker)),
|
||||
Span::raw(" "),
|
||||
Span::raw(desc.to_string()),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
fn pane_label(p: Pane) -> &'static str {
|
||||
match p {
|
||||
Pane::Library => "Library",
|
||||
Pane::Search => "Search",
|
||||
Pane::Ask => "Ask",
|
||||
Pane::Inspect => "Inspect",
|
||||
Pane::Jobs => "Jobs",
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
|
||||
let w = (area.width * percent_x / 100).max(40).min(area.width);
|
||||
let h = (area.height * percent_y / 100).max(10).min(area.height);
|
||||
let x = area.x + (area.width.saturating_sub(w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(h)) / 2;
|
||||
Rect::new(x, y, w, h)
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
mod app;
|
||||
mod ask;
|
||||
mod cheatsheet;
|
||||
mod editor;
|
||||
mod error_popup;
|
||||
mod ingest_progress;
|
||||
@@ -54,3 +55,7 @@ pub use search::debounce_due as search_debounce_due;
|
||||
// tests can pin the i/Esc behavior without standing up the full
|
||||
// run loop.
|
||||
pub use run::mode_intercept;
|
||||
// p9-fb-13: expose the cheatsheet-toggle intercept + render fn
|
||||
// for integration tests + future TUI consumers.
|
||||
pub use cheatsheet::render_cheatsheet;
|
||||
pub use run::cheatsheet_intercept;
|
||||
|
||||
@@ -130,6 +130,16 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> {
|
||||
if event::poll(POLL_INTERVAL)? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||
// p9-fb-13: cheatsheet popup toggle takes
|
||||
// precedence over both mode + pane dispatch.
|
||||
// F1 toggles open/close. While visible, Esc
|
||||
// also closes — and the rest of the dispatch
|
||||
// is skipped so `Esc` doesn't double as
|
||||
// "Insert→Normal" while the user is reading
|
||||
// the cheatsheet.
|
||||
if cheatsheet_intercept(app, key) {
|
||||
continue;
|
||||
}
|
||||
// p9-fb-12: global mode toggle. `Esc` from
|
||||
// Insert → Normal is intercepted here so it
|
||||
// works on every pane uniformly. `i` from
|
||||
@@ -261,6 +271,13 @@ fn render_root(f: &mut Frame, app: &App) {
|
||||
if let Some(err) = &app.error_overlay {
|
||||
render_error_overlay(f, f.area(), err, &app.theme);
|
||||
}
|
||||
// p9-fb-13: cheatsheet sits on top of the error overlay so the
|
||||
// user can summon help even mid-error (the cheatsheet's own
|
||||
// Esc/F1 close still works first; the next key reaches the
|
||||
// error-dismiss path).
|
||||
if app.cheatsheet_visible {
|
||||
crate::cheatsheet::render_cheatsheet(f, f.area(), app);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_ingest_status(f: &mut Frame, area: Rect, app: &App) {
|
||||
@@ -374,3 +391,41 @@ pub fn mode_intercept(app: &mut crate::app::App, key: crossterm::event::KeyEvent
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// p9-fb-13: cheatsheet popup interception. Returns `true` when
|
||||
/// consumed. Rules:
|
||||
/// - **`F1`** → toggle visibility (open if closed, close if open).
|
||||
/// Modifier-bearing variants (Ctrl-F1 etc.) are NOT the trigger.
|
||||
/// - **`Esc` while visible** → close. Returning `true` here means
|
||||
/// the global `mode_intercept` does NOT also see the Esc, so the
|
||||
/// user's "close cheatsheet" action stays a single keystroke
|
||||
/// instead of also flipping mode. **Trade-off**: a user in
|
||||
/// Insert mode with the cheatsheet open needs a SECOND `Esc` to
|
||||
/// flip to Normal. Single-effect-per-keystroke wins over
|
||||
/// compound actions.
|
||||
|
|
||||
/// - Any other key while visible → fall through (so the key reaches
|
||||
/// the active pane normally — useful if the user wants to keep
|
||||
/// the popup open and still navigate). The popup auto-closes
|
||||
/// only via F1 / Esc.
|
||||
///
|
||||
/// `pub` so integration tests can drive without standing up the
|
||||
/// full run loop.
|
||||
pub fn cheatsheet_intercept(app: &mut crate::app::App, key: crossterm::event::KeyEvent) -> bool {
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
let plain_or_shift =
|
||||
key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT;
|
||||
if !plain_or_shift {
|
||||
return false;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::F(1) => {
|
||||
app.cheatsheet_visible = !app.cheatsheet_visible;
|
||||
true
|
||||
}
|
||||
KeyCode::Esc if app.cheatsheet_visible => {
|
||||
app.cheatsheet_visible = false;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
156
crates/kebab-tui/tests/cheatsheet.rs
Normal file
156
crates/kebab-tui/tests/cheatsheet.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
//! p9-fb-13: cheatsheet popup. Tests `cheatsheet_intercept` (F1
|
||||
//! toggle, Esc close, modifier filter) and the rendered popup
|
||||
//! includes the expected pane sections.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use kebab_config::Config;
|
||||
use kebab_tui::{App, Pane, cheatsheet_intercept, render_cheatsheet};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
fn fresh_app(focus: Pane) -> App {
|
||||
let mut config = Config::defaults();
|
||||
config.storage.data_dir = "/tmp/kebab-tui-cheatsheet-tests-noop".to_string();
|
||||
config.workspace.root = "/tmp/kebab-tui-cheatsheet-tests-noop/workspace".to_string();
|
||||
let mut app = App::new(config).expect("App::new");
|
||||
app.focus = focus;
|
||||
app
|
||||
}
|
||||
|
||||
/// p9-fb-13: F1 toggles cheatsheet visibility. Consumed both ways.
|
||||
#[test]
|
||||
fn f1_toggles_cheatsheet_visibility() {
|
||||
let mut app = fresh_app(Pane::Library);
|
||||
assert!(!app.cheatsheet_visible(), "starts hidden");
|
||||
let consumed = cheatsheet_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(consumed, "F1 must be consumed");
|
||||
assert!(app.cheatsheet_visible(), "F1 opens");
|
||||
let consumed = cheatsheet_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(consumed, "second F1 also consumed");
|
||||
assert!(!app.cheatsheet_visible(), "F1 closes when open");
|
||||
}
|
||||
|
||||
/// p9-fb-13: Esc closes when visible (consumed). When hidden, Esc
|
||||
/// falls through (so the global mode_intercept / pane handlers
|
||||
/// keep their existing semantics).
|
||||
#[test]
|
||||
fn esc_closes_cheatsheet_when_visible_otherwise_falls_through() {
|
||||
let mut app = fresh_app(Pane::Library);
|
||||
// Hidden → Esc falls through.
|
||||
let consumed = cheatsheet_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(!consumed, "Esc with cheatsheet hidden must fall through");
|
||||
|
||||
// Visible → Esc closes + consumed.
|
||||
let _ = cheatsheet_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(app.cheatsheet_visible());
|
||||
let consumed = cheatsheet_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(consumed, "Esc with cheatsheet visible must consume");
|
||||
assert!(!app.cheatsheet_visible());
|
||||
}
|
||||
|
||||
/// p9-fb-13: modifier-bearing F1 (Ctrl-F1, Alt-F1) does NOT toggle.
|
||||
/// Reserves chord space for future bindings.
|
||||
#[test]
|
||||
fn modifier_keys_do_not_toggle_cheatsheet() {
|
||||
let mut app = fresh_app(Pane::Library);
|
||||
let consumed = cheatsheet_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::F(1), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(!consumed);
|
||||
assert!(!app.cheatsheet_visible());
|
||||
|
||||
let consumed = cheatsheet_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::F(1), KeyModifiers::ALT),
|
||||
);
|
||||
assert!(!consumed);
|
||||
assert!(!app.cheatsheet_visible());
|
||||
}
|
||||
|
||||
/// p9-fb-13: arbitrary keys (j, /, q, …) while cheatsheet visible
|
||||
/// fall through to the active pane. Popup auto-closes only via
|
||||
/// F1 / Esc, so the user can keep it open while navigating.
|
||||
#[test]
|
||||
fn arbitrary_key_falls_through_when_cheatsheet_visible() {
|
||||
let mut app = fresh_app(Pane::Library);
|
||||
let _ = cheatsheet_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(app.cheatsheet_visible());
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
] {
|
||||
let consumed = cheatsheet_intercept(&mut app, key);
|
||||
assert!(!consumed, "non-toggle keys fall through: {key:?}");
|
||||
assert!(app.cheatsheet_visible(), "popup stays open: {key:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// p9-fb-13: rendered popup includes the section headers + the
|
||||
/// global toggle keys + the active pane label. Buffer-grep style
|
||||
/// — same pattern P9-3's `render_grounded_answer_with_citation`
|
||||
/// uses to assert visible content.
|
||||
#[test]
|
||||
fn cheatsheet_popup_contains_global_and_pane_sections() {
|
||||
let mut app = fresh_app(Pane::Search);
|
||||
app.focus = Pane::Search;
|
||||
// Force visible — we're testing the renderer, not the toggle.
|
||||
let _ = cheatsheet_intercept(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE),
|
||||
);
|
||||
let backend = TestBackend::new(120, 40);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = Rect::new(0, 0, 120, 40);
|
||||
render_cheatsheet(f, area, &app);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
let rendered: String = (0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(rendered.contains("Global"), "Global section header present");
|
||||
assert!(rendered.contains("Library"), "Library section header present");
|
||||
assert!(rendered.contains("Search"), "Search section header present");
|
||||
assert!(rendered.contains("Ask"), "Ask section header present");
|
||||
assert!(rendered.contains("Inspect"), "Inspect section header present");
|
||||
assert!(rendered.contains("F1"), "F1 binding listed");
|
||||
assert!(rendered.contains("Esc"), "Esc binding listed");
|
||||
// The "currently focused: <pane>" line lives at the bottom of
|
||||
// the popup; it might get clipped if the popup's content
|
||||
// overflows the rect. Skip the assertion if the popup body
|
||||
// wraps too tall — the section-header asserts already cover
|
||||
// the primary contract.
|
||||
let has_focused = rendered.contains("focused");
|
||||
if !has_focused {
|
||||
eprintln!("[note] 'focused' line absent — likely body overflowed popup height; sections still pinned");
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,27 @@ historical contract that was implemented; this file accumulates the
|
||||
deltas so phase 5+ readers can find the live behavior without diffing
|
||||
git history.
|
||||
|
||||
## 2026-05-03 — p9-fb-13 cheatsheet: `?` → `F1` rebind
|
||||
|
||||
**Spec amended**: `tasks/p9/p9-fb-13-tui-cheatsheet.md` (frozen —
|
||||
original contract uses `?` as the cheatsheet trigger).
|
||||
|
||||
**Why rebind**: Library 가 이미 `Char('?')` 를 quick-Ask binding 으로
|
||||
사용 중 (`Pane::Library::handle_key_library` line ~305: `?` →
|
||||
`SwitchPane(Pane::Ask)`). spec 의 `?` 도입은 이 기존 binding 을 깨거나
|
||||
mode-aware override 가 필요한데, 후자는 mode machine 의 추가 special
|
||||
casing.
|
||||
|
||||
**Live binding**: `F1` (universal help key, no collision). modifier-
|
||||
bearing 변종 (Ctrl-F1 등) 은 미발동. cheatsheet 가 visible 인 동안
|
||||
`Esc` 도 닫기 (cheatsheet_intercept 가 mode_intercept 보다 먼저
|
||||
처리).
|
||||
|
||||
**Per-pane hint line redesign**: 별도 spec 항목 (verb-form hint
|
||||
재구성) 은 본 PR 에서 deferral. 기존 `render_footer` 의 pane-별
|
||||
힌트 문자열이 동일 역할을 하므로 사용자 경험상 누락 없음. 후속 PR
|
||||
가 mode-aware verb fragments 로 split 가능.
|
||||
|
||||
## 2026-05-03 — p9-fb-12 partial: mode machine without dispatch removal
|
||||
|
||||
**Spec amended**: `tasks/p9/p9-fb-12-tui-mode-machine.md` (status stays
|
||||
|
||||
@@ -3,7 +3,7 @@ phase: P9
|
||||
component: kebab-tui + README
|
||||
task_id: p9-fb-13
|
||||
title: "Cheatsheet popup (?) + README keymap table + verb hint line"
|
||||
status: planned
|
||||
status: in_progress
|
||||
depends_on: [p9-fb-12]
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
|
||||
Reference in New Issue
Block a user
의도 명확화 doc nit: cheatsheet 가 visible 인 상태에서 사용자가 INSERT 모드면,
Esc가 cheatsheet 만 닫고 mode flip 은 안 합니다 (cheatsheet_intercept가 mode_intercept 보다 먼저 dispatch). 사용자 expected: "Esc 누르면 cheatsheet 닫히고 mode 도 Normal 됨" 가능성.현재 동작 (단일 키스트로크 = 단일 효과) 도 합리적이지만, 사용자가 두 번 Esc 를 누르면 닫고 + mode flip 이 됨. 의도된 트레이드오프임을 doc 에 한 줄 추가하면 후속 사용자 피드백 시 reasoning 빠르게 트레이스 가능:
또 cheatsheet 본문이 hard-coded string table 이라 후속에서 키 binding 바뀌면 cheatsheet 가 stale 가능 — 별 항목으로 doc TODO 추가: