From 7e489babfa58d6cacd67e2ab0d143f570885a611 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Sat, 1 Aug 2026 21:03:59 +0100 Subject: [PATCH] TUI: one label convention for the key line, and a ? key map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contextual key line had grown to fourteen slots on the cockpit and rendered the same idea three ways: `d dispatch · D agent`, `n new · N plan`, and `r/R refine`. Settle on the combined form — one slot per lowercase/uppercase pair of a single action, labelled with the base verb — and give the uppercase glosses somewhere to live, since the line cannot carry them. `?` opens a per-screen key map: a peek-style overlay dismissed by any key, listing every binding that screen has, grouped into actions, navigation, and screen switching. It documents the keys no hint has ever carried (`o`, `g`, `a`, `l`, `J`/`K`, the page keys, `ctrl-r`, `1`-`4`) and the three uppercase variants, each worded to say what the shifted key does differently from its sibling. `?` is bound in the shared navigation block so it also reaches the projects and Config screens, which intercept before the trailing match. With the map in place the line keeps only what changes a task's state or destiny: `x` score, `h` history, `c` docs and `l` log come off it. No key is rebound, and the worst-case cockpit line is now ten slots. `key_hints` is now a filter over `hint_candidates`, which lists every slot a screen can show with a flag for whether the selection earns it — that is what lets the drift test check the whole set, gated slots included, against the screen's key map from a single App. --- CHANGELOG.md | 10 + crates/voro/src/app.rs | 26 +- crates/voro/src/ui.rs | 607 +++++++++++++++++++++++++++++++++++------ docs/DESIGN.md | 2 + 4 files changed, 543 insertions(+), 102 deletions(-) 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.