diff --git a/CHANGELOG.md b/CHANGELOG.md index 37a908e..34df60e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rather than the workhorse. Agent templates gained a `{model}` placeholder and a per-agent `model`/`model_deep`/`model_plan` map to fill it; an agent naming no models, such as the built-in `codex`, ignores the flag entirely. +- `?` in the TUI opens the current screen's complete key map — actions, + navigation, and screen switching — including the keys no hint ever advertised + (`o` open in a viewer, `g` open the PR, `a` attach, `l` page the log, `J`/`K` + and the page keys, `ctrl-r`). Any key closes it again. ### Changed @@ -66,6 +70,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 body, branch name, document title or project name containing `{task_id}`, `{db}`, `{note}`, `{seed}`, `{branch}` or `{docs}` now reaches the agent verbatim instead of being rewritten before it is read. +- The TUI's contextual key line is shorter and speaks one language. A lowercase + key and its shifted sibling now share a single slot labelled with the base + verb — `d/D dispatch`, `r/R refine`, `n/N new` — instead of three different + renderings of the same idea, and the line keeps only what changes a task's + state or destiny: `x` score, `h` history, `c` docs and `l` log still work but + are documented in the `?` map rather than on the line. No key was rebound. - `projects.path` is gone. Existing databases convert in place: every project's old path becomes its default repo, and existing tasks resolve to exactly the checkouts they had before. `voro project add` and `voro project path` keep diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index a3fd944..da9188f 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -200,6 +200,10 @@ pub enum Mode { editing: bool, review_project: Option, }, + /// The current screen's full key map (DESIGN.md §9), opened with `?`. It is + /// a peek rather than a screen — any key dismisses it — and it carries no + /// state of its own, since the screen it describes is the App's. + KeyMap, /// Picking `default_agent` or `default_viewer` from the configured set /// (DESIGN.md §5), on the Config screen. DefaultPicker { @@ -953,6 +957,9 @@ impl App { editing, review_project, } => self.key_viewer_form(key, name, cmd, on_cmd, editing, review_project), + // The key map is dismissed by any key, and `on_key` has already + // restored `Mode::Normal`, so there is nothing left to do. + Mode::KeyMap => {} Mode::DefaultPicker { kind, names, @@ -963,13 +970,18 @@ impl App { } fn key_normal(&mut self, key: KeyEvent) { - // Navigation shared by every screen: quit, tab cycling, and moving the - // selection. + // Navigation shared by every screen: quit, the key map, tab cycling, + // and moving the selection. `?` belongs here rather than in the + // trailing match, which the projects and Config screens never reach. match key.code { KeyCode::Char('q') => { self.should_quit = true; return; } + KeyCode::Char('?') => { + self.mode = Mode::KeyMap; + return; + } KeyCode::Tab => { self.toggle_screen(); return; @@ -1200,13 +1212,6 @@ impl App { self.store.repos(project_id).map(|r| r.len()).unwrap_or(1) } - /// The selected task's newest-session log path, whatever its state — what - /// gates the `l` key and its key-line hint. - pub fn selected_session_log(&self) -> Option<&str> { - let id = self.selected_task_id()?; - self.last_sessions.get(&id)?.log_path.as_deref() - } - /// Whether the selection is a review task, so it can be handed off with /// `w` — what gates that key's key-line hint. pub fn selected_can_hand_off(&self) -> bool { @@ -3752,7 +3757,6 @@ mod tests { assert_eq!(session.outcome, Some(voro_core::SessionOutcome::Failed)); assert!(session.ended_at.is_some()); assert_eq!(session.log_path.as_deref(), Some("/tmp/demo/s.log")); - assert_eq!(app.selected_session_log(), Some("/tmp/demo/s.log")); } /// `l` on a stalled task queues `$PAGER ` for main() to run with the @@ -3797,7 +3801,6 @@ mod tests { app.store.task(task.id).unwrap().state, TaskState::NeedsInput ); - assert_eq!(app.selected_session_log(), Some("/tmp/demo/open.log")); key(&mut app, KeyCode::Char('l')); let request = app.pending_attach.clone().expect("a pager request"); @@ -3810,7 +3813,6 @@ mod tests { #[test] fn log_key_on_a_ready_task_reports_and_does_nothing() { let mut app = app_with(&[TaskState::Ready]); - assert!(app.selected_session_log().is_none()); key(&mut app, KeyCode::Char('l')); assert!(app.pending_attach.is_none()); diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index 0b155bb..b9f15d3 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -44,6 +44,7 @@ pub fn draw(frame: &mut Frame, app: &App) { fn draw_mode(frame: &mut Frame, app: &App) { match &app.mode { Mode::Normal => {} + Mode::KeyMap => draw_key_map(frame, app), Mode::AddProject { name, path, @@ -1492,100 +1493,313 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(Line::from(spans), area); } -/// The contextual per-screen key line (ui-redesign §2): the actions that apply -/// on the current screen and selection, as key/label pairs the caller renders -/// key-bold, label-dim. It lists actions, not navigation (`j`/`k` and `ctrl-r` -/// refresh are omitted); `q` and `tab` are always present. Selection-only -/// actions drop out on the cockpit when nothing is selected, and the refine -/// keys appear only on a proposal, which is all they act on. /// Whether the selection is a proposal, which is the only thing refine acts on. fn selection_is_proposed(app: &App) -> bool { app.selected_task_id().is_some_and(|id| app.is_proposed(id)) } -fn key_hints(app: &App) -> Vec<(&'static str, &'static str)> { +/// Every slot the current screen's key line can hold, each flagged with whether +/// this selection earns it. [`key_hints`] is this list filtered, so the two can +/// never disagree about which keys the line advertises — which is what lets the +/// drift test check the whole set against [`key_map`] from one App. +fn hint_candidates(app: &App) -> Vec<(&'static str, &'static str, bool)> { // `enter_hint` yields "⏎ "; split the glyph from the verb so the // glyph renders as the bold key and the verb as the dim label. - let enter = app - .enter_hint() - .and_then(|h| h.split_once(' ')) - .map(|(_, verb)| ("⏎", verb)); + let enter = app.enter_hint().and_then(|h| h.split_once(' ')); + let enter = ("⏎", enter.map_or("act", |(_, verb)| verb), enter.is_some()); + let selected = app.selected_task_id().is_some(); match app.screen { + Screen::Cockpit => vec![ + enter, + ("d/D", "dispatch", selected), + ("r/R", "refine", selection_is_proposed(app)), + ("s", "state", true), + ("!", "deep", selected), + ("w", "wait", app.selected_can_hand_off()), + ("n/N", "new", true), + ("e", "edit", true), + ("?", "keys", true), + ("tab", "tasks", true), + ("q", "quit", true), + ], + Screen::Tasks => vec![ + enter, + ("w", "wait", app.selected_can_hand_off()), + ("r/R", "refine", selection_is_proposed(app)), + ("s", "state", true), + ("!", "deep", true), + ("n/N", "new", true), + ("e", "edit", true), + ("?", "keys", true), + ("tab", "projects", true), + ("q", "quit", true), + ], + // `a`/`A` and the rest of this screen's uppercase keys are unrelated + // actions sharing a letter, not variants of one action, so they keep + // their own slots. + Screen::Projects => vec![ + ("0-5", "weight", true), + ("r", "rename", true), + ("a", "add", true), + ("A", "archive", true), + ("d", "delete", true), + ("v", "review action", true), + ("?", "keys", true), + ("tab", "config", true), + ("q", "quit", true), + ], + Screen::Config => { + let viewers = !app.config_viewers.is_empty(); + vec![ + ("a", "add viewer", true), + ("e", "edit", viewers), + ("d", "delete", viewers), + ("V", "default viewer", viewers), + ("A", "default agent", true), + ("?", "keys", true), + ("tab", "cockpit", true), + ("q", "quit", true), + ] + } + } +} + +/// The contextual per-screen key line (DESIGN.md §9): the actions that apply on +/// the current screen and selection, as key/label pairs the caller renders +/// key-bold, label-dim. A lowercase/uppercase pair of one action takes a single +/// slot keyed on the pair (`d/D dispatch`), with the uppercase variant's gloss +/// left to `?`. The line carries what changes a task's state or destiny; +/// navigation, display toggles and browsing conveniences live in the key map +/// only, so `?` is always present. Selection-only actions drop out when there +/// is nothing to act on, and the refine keys appear only on a proposal. +fn key_hints(app: &App) -> Vec<(&'static str, &'static str)> { + hint_candidates(app) + .into_iter() + .filter(|(_, _, shown)| *shown) + .map(|(key, label, _)| (key, label)) + .collect() +} + +/// The lowercase/uppercase pairs the key line renders as one slot (DESIGN.md +/// §9). This map is the only place the uppercase variants are glossed, so the +/// three lines are worded to one shape: each says what the shifted key does +/// *differently* from its lowercase sibling. +const DISPATCH_KEYS: [(&str, &str); 2] = [ + ("d", "dispatch to the resolved agent"), + ("D", "dispatch, choosing the agent first"), +]; +const REFINE_KEYS: [(&str, &str); 2] = [ + ("r", "refine a proposal, leaving a note"), + ("R", "refine a proposal, talking to an agent"), +]; +const NEW_KEYS: [(&str, &str); 2] = [ + ("n", "new task, written in $EDITOR"), + ("N", "new task, planned with an agent"), +]; + +/// A titled group of key/label pairs in the key map. +type KeySection = (&'static str, Vec<(&'static str, &'static str)>); + +/// A screen's complete key map (DESIGN.md §9), grouped into actions, +/// navigation, and screen switching. Unlike [`key_hints`] it is ungated by +/// selection — it is the map, so it lists every key the screen binds, including +/// the ones the line has no room to advertise. +fn key_map(screen: Screen) -> Vec { + let pairs = |set: [(&'static str, &'static str); 2]| set.into_iter(); + let screens = |current: &'static str| { + ( + "Screens", + vec![ + ("tab", current), + ("1", "cockpit"), + ("2", "tasks"), + ("3", "projects"), + ("4", "config"), + ], + ) + }; + match screen { Screen::Cockpit => { - let mut pairs: Vec<(&'static str, &'static str)> = Vec::new(); - pairs.extend(enter); - if app.selected_task_id().is_some() { - pairs.push(("d", "dispatch")); - pairs.push(("D", "agent")); - } - if selection_is_proposed(app) { - pairs.push(("r/R", "refine")); - } - pairs.push(("s", "state")); - if app.selected_task_id().is_some() { - pairs.push(("!", "deep")); - pairs.push(("c", "docs")); - pairs.push(("x", "score")); - pairs.push(("h", "history")); - } - if app.selected_session_log().is_some() { - pairs.push(("l", "log")); - } - if app.selected_can_hand_off() { - pairs.push(("w", "wait")); - } - pairs.push(("n", "new")); - pairs.push(("N", "plan")); - pairs.push(("e", "edit")); - pairs.push(("tab", "tasks")); - pairs.push(("q", "quit")); - pairs + let mut actions = vec![("⏎", "act on the selected row")]; + actions.extend(pairs(DISPATCH_KEYS)); + actions.extend(pairs(REFINE_KEYS)); + actions.extend([ + ("s", "change state"), + ("!", "toggle deep — the agent's strongest model"), + ("c", "link and unlink documents"), + ("x", "fold the score decomposition into the card"), + ("h", "fold the task's history into the card"), + ("o", "open the diff in a viewer"), + ("g", "open the tracked PR"), + ("a", "attach to the task's session"), + ("l", "page the session log"), + ("w", "hand a review task off, to wait"), + ]); + actions.extend(pairs(NEW_KEYS)); + actions.push(("e", "edit the selected task")); + vec![ + ("Actions", actions), + ( + "Navigation", + vec![ + ("j/k", "move the selection"), + ("J/K", "scroll the card"), + ("PgUp/PgDn", "page the card"), + ("ctrl-r", "refresh"), + ("?", "this key map"), + ("q", "quit"), + ], + ), + screens("next screen"), + ] } Screen::Tasks => { - let mut pairs: Vec<(&'static str, &'static str)> = Vec::new(); - pairs.extend(enter); - if app.selected_session_log().is_some() { - pairs.push(("l", "log")); - } - if app.selected_can_hand_off() { - pairs.push(("w", "wait")); - } - if selection_is_proposed(app) { - pairs.push(("r/R", "refine")); - } - pairs.push(("s", "state")); - pairs.push(("!", "deep")); - pairs.push(("c", "docs")); - pairs.push(("n", "new")); - pairs.push(("N", "plan")); - pairs.push(("e", "edit")); - pairs.push(("tab", "projects")); - pairs.push(("q", "quit")); - pairs + let mut actions = vec![("⏎", "open the task's detail")]; + actions.extend(pairs(DISPATCH_KEYS)); + actions.extend(pairs(REFINE_KEYS)); + actions.extend([ + ("s", "change state"), + ("!", "toggle deep — the agent's strongest model"), + ("c", "link and unlink documents"), + ("o", "open the diff in a viewer"), + ("g", "open the tracked PR"), + ("a", "attach to the task's session"), + ("l", "page the session log"), + ("w", "hand a review task off, to wait"), + ]); + actions.extend(pairs(NEW_KEYS)); + actions.push(("e", "edit the selected task")); + vec![ + ("Actions", actions), + ( + "Navigation", + vec![ + ("j/k", "move the selection"), + ("ctrl-r", "refresh"), + ("?", "this key map"), + ("q", "quit"), + ], + ), + screens("next screen"), + ] } Screen::Projects => vec![ - ("0-5", "weight"), - ("r", "rename"), - ("a", "add"), - ("A", "archive"), - ("d", "delete"), - ("v", "review action"), - ("tab", "config"), - ("q", "quit"), + ( + "Actions", + vec![ + ("0-5", "set the project's weight"), + ("r", "rename or re-path the project"), + ("a", "add a project"), + ("A", "archive or unarchive the project"), + ("d", "delete the project — only when it is empty"), + ("v", "pick the project's review action"), + ], + ), + ( + "Navigation", + vec![ + ("j/k", "move the selection"), + ("?", "this key map"), + ("q", "quit"), + ], + ), + // The digit keys are weights here, so tab is the only way out. + ("Screens", vec![("tab", "next screen")]), ], - Screen::Config => { - let mut pairs: Vec<(&'static str, &'static str)> = vec![("a", "add viewer")]; - if !app.config_viewers.is_empty() { - pairs.push(("e", "edit")); - pairs.push(("d", "delete")); - pairs.push(("V", "default viewer")); - } - pairs.push(("A", "default agent")); - pairs.push(("tab", "cockpit")); - pairs.push(("q", "quit")); - pairs + Screen::Config => vec![ + ( + "Actions", + vec![ + ("a", "add a viewer"), + ("⏎/e", "edit the selected viewer's command"), + ("d", "delete the selected viewer"), + ("V", "pick the default viewer"), + ("A", "pick the default agent"), + ], + ), + ( + "Navigation", + vec![ + ("j/k", "move the selection"), + ("?", "this key map"), + ("q", "quit"), + ], + ), + ( + "Screens", + vec![ + ("tab", "next screen"), + ("1", "cockpit"), + ("2", "tasks"), + ("3", "projects"), + ], + ), + ], + } +} + +/// The key map's rows for one column, keys right-aligned to the column's widest. +fn key_map_column(sections: &[KeySection]) -> Vec>> { + let key_w = sections + .iter() + .flat_map(|(_, entries)| entries.iter()) + .map(|(key, _)| key.chars().count()) + .max() + .unwrap_or(0); + let mut rows: Vec>> = Vec::new(); + for (i, (title, entries)) in sections.iter().enumerate() { + if i > 0 { + rows.push(Vec::new()); + } + rows.push(vec![Span::styled(*title, Style::new().bold())]); + for (key, label) in entries { + rows.push(vec![ + Span::styled(format!("{key:>key_w$} "), Style::new().bold()), + Span::styled(*label, Style::new().dim()), + ]); } } + rows +} + +/// The `?` overlay: the current screen's whole key map, actions in one column +/// and navigation over screen switching in the other so a screenful fits. +fn draw_key_map(frame: &mut Frame, app: &App) { + let sections = key_map(app.screen); + let (actions, rest) = sections.split_at(1); + let (left, right) = (key_map_column(actions), key_map_column(rest)); + let width_of = + |row: &Vec>| -> usize { row.iter().map(|s| s.content.chars().count()).sum() }; + let left_w = left.iter().map(width_of).max().unwrap_or(0); + let right_w = right.iter().map(width_of).max().unwrap_or(0); + const GAP: usize = 3; + + let lines: Vec> = (0..left.len().max(right.len())) + .map(|i| { + let mut spans = left.get(i).cloned().unwrap_or_default(); + if let Some(row) = right.get(i) { + let pad = (left_w + GAP).saturating_sub(width_of(&spans)); + spans.push(Span::raw(" ".repeat(pad))); + spans.extend(row.iter().cloned()); + } + Line::from(spans) + }) + .collect(); + + let screen = match app.screen { + Screen::Cockpit => "cockpit", + Screen::Tasks => "tasks", + Screen::Projects => "projects", + Screen::Config => "config", + }; + let width = (left_w + GAP + right_w + 2) as u16; + let area = popup_area(frame, width, lines.len() as u16 + 2); + let para = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title(format!("Keys — {screen} — any key closes")), + ); + frame.render_widget(para, area); } /// A centred popup rect, cleared of what is beneath it. @@ -2510,8 +2724,8 @@ mod tests { /// The cockpit detail pane answers "what happened" for a stalled task /// (task #73): the dead session's outcome, agent, end time, and log path - /// render under the metadata, and the key line advertises `l`. A capped - /// session reads `capped`; a clean ready task carries none of it. + /// render under the metadata. A capped session reads `capped`; a clean + /// ready task carries none of it. #[test] fn detail_pane_shows_a_stalled_tasks_session_post_mortem() { use crate::app::App; @@ -2565,8 +2779,6 @@ mod tests { assert!(rendered.contains("claude"), "{rendered}"); assert!(rendered.contains("ended 2"), "{rendered}"); assert!(rendered.contains("log: /tmp/voro/s.log"), "{rendered}"); - let labels: Vec<&str> = key_hints(&failed).iter().map(|(_, l)| *l).collect(); - assert!(labels.contains(&"log"), "{labels:?}"); let capped = app_with_session(true); assert!(render(&capped).contains("last session: capped")); @@ -2643,8 +2855,6 @@ mod tests { assert!(rendered.contains("started 2"), "{rendered}"); assert!(rendered.contains("log: /tmp/voro/open.log"), "{rendered}"); assert!(!rendered.contains("last session:"), "{rendered}"); - let labels: Vec<&str> = key_hints(&app).iter().map(|(_, l)| *l).collect(); - assert!(labels.contains(&"log"), "{labels:?}"); } /// A multi-line question renders across multiple lines in the cockpit @@ -2707,11 +2917,11 @@ mod tests { assert!(rows.iter().any(|r| r.contains("Bravo option")), "{rows:?}"); } - /// The cockpit key line only advertises the score/history toggles and the - /// dispatch keys while a task is selected — with an empty queue there is - /// nothing for them to act on, so they drop out. + /// The cockpit key line only advertises the selection-only actions while a + /// task is selected — with an empty queue there is nothing for them to act + /// on, so they drop out. #[test] - fn cockpit_key_line_drops_score_and_history_without_a_selection() { + fn cockpit_key_line_drops_the_selection_only_actions_without_a_selection() { use crate::app::App; use voro_core::{NewTask, Store}; @@ -2725,7 +2935,7 @@ mod tests { assert_eq!(empty.screen, Screen::Cockpit); assert!(empty.selected_task_id().is_none()); let labels: Vec<&str> = key_hints(&empty).iter().map(|(_, l)| *l).collect(); - for dropped in ["score", "history", "dispatch", "agent"] { + for dropped in ["dispatch", "deep"] { assert!( !labels.contains(&dropped), "empty cockpit should not advertise {dropped}: {labels:?}" @@ -2751,7 +2961,7 @@ mod tests { let selected = App::new(store, ctx()).unwrap(); assert!(selected.selected_task_id().is_some()); let labels: Vec<&str> = key_hints(&selected).iter().map(|(_, l)| *l).collect(); - for shown in ["score", "history", "dispatch", "agent"] { + for shown in ["dispatch", "deep"] { assert!( labels.contains(&shown), "cockpit with a selection should advertise {shown}: {labels:?}" @@ -2759,6 +2969,223 @@ mod tests { } } + /// A lowercase/uppercase pair of one action takes a single slot keyed on + /// the pair, and the line stays short enough to scan: ten slots or fewer on + /// every row of a queue holding one of each kind (DESIGN.md §9). + #[test] + fn the_key_line_pairs_its_slots_and_stays_at_ten() { + use crate::app::App; + use ratatui::crossterm::event::{KeyCode, KeyEvent}; + use voro_core::{Action, NewTask, Store}; + + let mut store = Store::open_in_memory().unwrap(); + let p = store.create_project("voro", "/tmp/voro").unwrap(); + store.set_weight(p.id, 3).unwrap(); + // A proposal earns the refine slot and a review task the hand-off slot; + // between them every conditional cockpit slot is covered. + let task = |title: &str, state: TaskState| NewTask { + project_id: p.id, + repo_id: None, + title: title.into(), + body: String::new(), + priority: Priority::P2, + state, + agent: None, + human: false, + deep: false, + }; + store + .create_task(task("a proposal", TaskState::Proposed)) + .unwrap(); + store + .create_task(task("ready to go", TaskState::Ready)) + .unwrap(); + let reviewed = store + .create_task(task("in review", TaskState::Ready)) + .unwrap(); + store.apply(reviewed.id, Action::Start).unwrap(); + store.apply(reviewed.id, Action::Complete(None)).unwrap(); + let mut app = App::new( + store, + crate::dispatch::DispatchCtx::without_config(std::path::Path::new( + "/nonexistent/voro.db", + )), + ) + .unwrap(); + + let mut seen_refine = false; + let mut seen_wait = false; + for screen in [ + Screen::Cockpit, + Screen::Tasks, + Screen::Projects, + Screen::Config, + ] { + app.screen = screen; + let mut i = 0; + loop { + let rows = match screen { + // Re-read each time: folding a digest open below adds rows. + Screen::Cockpit => app.cockpit_rows.len(), + Screen::Tasks => app.all.len(), + _ => 1, + }; + if i >= rows { + break; + } + match screen { + Screen::Cockpit => app.cockpit_sel = i, + Screen::Tasks => app.tasks_sel = i, + _ => {} + } + // Proposals ride as a digest row; fold it open so the proposal + // itself — the row the refine slot answers on — is selectable. + if app.enter_hint() == Some("⏎ expand") { + app.on_key(KeyEvent::from(KeyCode::Enter)); + } + let keys: Vec<&str> = key_hints(&app).iter().map(|(k, _)| *k).collect(); + assert!( + keys.len() <= 10, + "{screen:?} row {i} shows {} slots: {keys:?}", + keys.len() + ); + assert!(keys.contains(&"?"), "{screen:?} must advertise ?: {keys:?}"); + // The task screens pair a lowercase key with its uppercase + // variant; the other two bind unrelated actions to the same + // letter, so their slots stay apart. + if matches!(screen, Screen::Cockpit | Screen::Tasks) { + for lone in ["d", "D", "r", "R", "n", "N"] { + assert!( + !keys.contains(&lone), + "{screen:?} should pair {lone} into one slot: {keys:?}" + ); + } + } + seen_refine |= keys.contains(&"r/R"); + seen_wait |= keys.contains(&"w"); + i += 1; + } + } + assert!( + seen_refine && seen_wait, + "the conditional slots never showed" + ); + } + + /// The line may drop a key, but the map may not: every key the hint line + /// can show is in that screen's key map, so trimming the line never makes + /// a key undiscoverable (DESIGN.md §9). + #[test] + fn every_hinted_key_appears_in_the_key_map() { + use crate::app::App; + + // A combined slot (`d/D`, `j/k`) stands for its individual keys on both + // sides, so compare key by key. + let split = |key: &'static str| key.split('/').collect::>(); + for screen in [ + Screen::Cockpit, + Screen::Tasks, + Screen::Projects, + Screen::Config, + ] { + let mut app = App::new( + voro_core::Store::open_in_memory().unwrap(), + crate::dispatch::DispatchCtx::without_config(std::path::Path::new( + "/nonexistent/voro.db", + )), + ) + .unwrap(); + app.screen = screen; + let mapped: Vec<&str> = key_map(screen) + .iter() + .flat_map(|(_, entries)| entries.iter()) + .flat_map(|(key, _)| split(key)) + .collect(); + for (key, ..) in hint_candidates(&app) { + for one in split(key) { + assert!( + mapped.contains(&one), + "{screen:?} hints {key:?} but its key map omits {one:?}: {mapped:?}" + ); + } + } + } + } + + /// `?` opens the current screen's map from any screen, listing the keys the + /// line has no room for and the gloss for each uppercase variant; any key + /// closes it again (DESIGN.md §9). + #[test] + fn the_key_map_overlay_opens_on_question_mark_and_closes_on_any_key() { + use crate::app::App; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::crossterm::event::{KeyCode, KeyEvent}; + + let mut app = App::new( + voro_core::Store::open_in_memory().unwrap(), + crate::dispatch::DispatchCtx::without_config(std::path::Path::new( + "/nonexistent/voro.db", + )), + ) + .unwrap(); + app.screen = Screen::Projects; + app.on_key(KeyEvent::from(KeyCode::Char('?'))); + assert!(matches!(app.mode, Mode::KeyMap)); + + let width: u16 = 120; + let mut terminal = Terminal::new(TestBackend::new(width, 40)).unwrap(); + terminal.draw(|f| draw(f, &app)).unwrap(); + let rendered: String = terminal + .backend() + .buffer() + .content() + .chunks(width as usize) + .map(|row| row.iter().map(|c| c.symbol()).collect::()) + .collect::>() + .join("\n"); + assert!(rendered.contains("Keys — projects"), "{rendered}"); + assert!(rendered.contains("archive or unarchive"), "{rendered}"); + + // Any key is a dismissal, including one the screen binds. + app.on_key(KeyEvent::from(KeyCode::Char('a'))); + assert!(matches!(app.mode, Mode::Normal)); + + // The cockpit's map is the longest, and it has to fit the smallest + // terminal worth supporting — 80x24 clips nothing. + app.screen = Screen::Cockpit; + app.on_key(KeyEvent::from(KeyCode::Char('?'))); + let width: u16 = 80; + let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap(); + terminal.draw(|f| draw(f, &app)).unwrap(); + let rendered: String = terminal + .backend() + .buffer() + .content() + .chunks(width as usize) + .map(|row| row.iter().map(|c| c.symbol()).collect::()) + .collect::>() + .join("\n"); + // The keys the trimmed line no longer carries, and the uppercase + // glosses it never carried. + for expected in [ + "attach to the task's session", + "page the session log", + "fold the score decomposition", + "dispatch, choosing the agent first", + "new task, planned with an agent", + "refine a proposal, talking to an agent", + // The right-hand column, whole — nothing clipped at 80 columns. + "page the card", + "next screen", + ] { + assert!( + rendered.contains(expected), + "{expected:?} missing:\n{rendered}" + ); + } + } + #[test] fn non_parked_and_blockerless_rows_get_no_suffix() { assert!( diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 302b576..b7d773b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -315,6 +315,8 @@ The TUI is built first and is the primary interface throughout. Ratatui, three r Beyond the cockpit, the TUI cycles (Tab, or `1`–`4`) through three further full-screen views: the **task browser**, the **projects screen** (weights, archive, and the per-project review action), and a **Config screen** that renders and edits the `voro.toml` surface (§5) — the effective agents read-only with provenance and the default marked, and the named viewers editable in place (add, change command, delete, and pick `default_viewer`/`default_agent`) through the comment-preserving write helper. DB-backed configuration (projects, weights, review actions) stays on the projects screen; the Config screen is the voro.toml view. The projects screen's review-action picker also offers a "new viewer…" entry that opens the same add-viewer form and selects the new viewer for that project, so first-time viewer setup needs no detour through the Config screen. +**Keys are advertised in two places, and the split between them is deliberate.** A contextual key line sits under every screen, listing the actions that apply to the current screen and selection — and only those that change a task's state or destiny, since a line the operator has to read twice has stopped being contextual. Where a lowercase key and its shifted sibling are two ways of doing *one* action, they take a single slot keyed on the pair and labelled with the base verb (`d/D dispatch`, `r/R refine`, `n/N new`); keys that merely share a letter without sharing an action — the projects screen's `a` add and `A` archive, the Config screen's `a` add viewer and `A` default agent — keep their own slots, because pairing them would claim a kinship that is not there. What each uppercase variant does differently is spelled out one level down, in the **`?` key map**: a peek-style overlay, dismissed by any key, listing the current screen's *complete* bindings grouped into actions, navigation, and screen switching. The map is what licenses the line's brevity — navigation, display toggles like `x`/`h`, and browsing conveniences like `l` and `o` are reachable and documented without ever crowding the line — so `?` itself is the one key every screen's line always carries. + The first milestone deliberately restricts scope to three lists and a handful of keybindings — the risk of TUI-first is polishing panes before the workflow is validated, and the mitigation is scope, not sequence. Core interactions, roughly in order of implementation: create/edit a task in `$EDITOR` (title, body, priority, deps, agent override via frontmatter or a form) — or plan one interactively with an agent (§8's planning sessions, on the sibling key); edit project weights on a dedicated projects screen — one row per project, weight set by a single keystroke (*this must be fast — it happens every morning*); resume a queued question once it is answered in the agent's session; dispatch a ready task (default agent) and dispatch-via-picker; accept/reject a review item; triage `proposed` tasks from the queue; redispatch a stalled task; a score-decomposition view folded inline into any task's detail (toggled with `x`, not a popup). Every action ultimately gets a CLI equivalent so the whole tool is scriptable and agent-legible, but the human-facing CLI trails the TUI rather than preceding it.