From 122c97bcf36c3d448e42282a153265d0d13cf11d Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Wed, 5 Aug 2026 12:45:04 +0100 Subject: [PATCH] State what the case of a key means, and audit the bindings against it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #327 gave the key line one label format for a lowercase key and its shifted sibling, but left the convention behind that format unstated — so `d/D dispatch` promised a kinship nothing in the doc defined, and the uppercase glosses in the `?` map were worded to a shape rather than to a rule. The rule is foreground versus background: the lowercase key acts immediately and headlessly and the operator never leaves the TUI, and the uppercase key opens an interactive surface — an agent session or a picker. Taking a line of text inline is not opening a surface; it is how a lowercase key takes its argument, which is what keeps `r` and `a` on the lowercase side. It binds pairs and only pairs, which is the same line §9 already draws between a shifted sibling and a key that merely shares a letter. That puts `C`, the projects screen's `A`, `J`/`K` and the Config screen's `V`/`A` outside it rather than in breach of it, and none is worth rebinding to buy a consistency nobody reads. The audit found no binding to move: `d`/`D`, `r`/`R` and `a`/`A` already conform (the picker is `D`'s interactive surface, and `r`'s backgrounding is what the lowercase key means, not the defect #325 read it as), and `n`/`N` is the one pair still out of line — both halves are foreground today, which #315 fixes by making `n` a typed line expanded by a background agent. #315 is now ordered behind this task so the rule lands first. A test replaces the audit going forward: every uppercase key in every screen's map must be half of a pair or one of the exceptions named in §9, so a new uppercase binding cannot be added without deciding which. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SB1A345BtsrvujQR3njwx9 --- crates/voro/src/ui.rs | 72 ++++++++++++++++++++++++++++++++++++------- docs/DESIGN.md | 4 +++ 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index 004519d..26d979c 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -1737,22 +1737,40 @@ fn key_hints(app: &App) -> Vec<(&'static str, &'static str)> { /// 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. +/// three lines are worded to one shape, the one the case convention asks for: +/// the lowercase acts headlessly and stays in the TUI, the uppercase names the +/// surface it opens. const DISPATCH_KEYS: [(&str, &str); 2] = [ - ("d", "dispatch to the resolved agent"), - ("D", "dispatch, choosing the agent first"), + ("d", "dispatch to the resolved agent, headless"), + ("D", "dispatch, choosing the agent in a picker"), ]; const REFINE_KEYS: [(&str, &str); 2] = [ - ("r", "refine a brief, leaving a note"), - ("R", "refine a brief, talking to an agent"), + ("r", "refine a brief from a note, headless"), + ("R", "refine a brief in an agent session"), ]; const NEW_KEYS: [(&str, &str); 2] = [ ("n", "new task, written in $EDITOR"), - ("N", "new task, planned with an agent"), + ("N", "new task, planned in an agent session"), +]; + +/// The uppercase keys DESIGN.md §9 names as standing outside the case +/// convention, because none is the shifted half of a pair: `C` and the projects +/// screen's `A` share a letter with an unrelated action, `J`/`K` scroll the +/// card, and the Config screen's `V`/`A` pick defaults. Every other uppercase +/// binding has to be the interactive half of a pair, which the test below +/// enforces screen by screen. +#[cfg(test)] +const CASE_EXCEPTIONS: [(Screen, &str); 7] = [ + (Screen::Cockpit, "C"), + (Screen::Cockpit, "J"), + (Screen::Cockpit, "K"), + (Screen::Tasks, "C"), + (Screen::Projects, "A"), + (Screen::Config, "V"), + (Screen::Config, "A"), ]; const MESSAGE_KEYS: [(&str, &str); 2] = [ - ("a", "message the task's session, without leaving"), + ("a", "message the task's session, headless"), ("A", "message it in person — attach or resume"), ]; @@ -3480,6 +3498,38 @@ mod tests { } } + /// The case convention (DESIGN.md §9): an uppercase key is the interactive + /// half of a lowercase/uppercase pair, or one of the exceptions the doc + /// names. A new uppercase binding that is neither fails here, which is the + /// point — it is the prompt to decide which of the two it is. + #[test] + fn every_uppercase_key_is_paired_or_a_named_exception() { + let paired: Vec<&str> = [DISPATCH_KEYS, REFINE_KEYS, NEW_KEYS, MESSAGE_KEYS] + .iter() + .flat_map(|set| set.iter()) + .map(|(key, _)| *key) + .collect(); + for screen in [ + Screen::Cockpit, + Screen::Tasks, + Screen::Projects, + Screen::Config, + ] { + let uppercase = key_map(screen) + .into_iter() + .flat_map(|(_, entries)| entries) + .flat_map(|(key, _)| key.split('/').collect::>()) + .filter(|key| key.chars().count() == 1 && key.chars().all(char::is_uppercase)); + for key in uppercase { + assert!( + paired.contains(&key) || CASE_EXCEPTIONS.contains(&(screen, key)), + "{screen:?} binds {key:?} uppercase, but it is neither half of a pair \ + nor a documented exception — see DESIGN.md §9" + ); + } + } + } + /// `?` 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). @@ -3548,9 +3598,9 @@ mod tests { "message it in person — attach or resume", "page the session log", "fold the score decomposition", - "dispatch, choosing the agent first", - "new task, planned with an agent", - "refine a brief, talking to an agent", + "dispatch, choosing the agent in a picker", + "new task, planned in an agent session", + "refine a brief in an agent session", // The right-hand column, whole — nothing clipped at 80 columns. "page the card", "next screen", diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1477bd6..d1ef452 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -331,6 +331,10 @@ Beyond the cockpit, the TUI cycles (Tab, or `1`–`4`) through three further ful **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`, `a/A message`); keys that merely share a letter without sharing an action — the cockpit's `c` link documents and `C` cancel a refine, 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. +**What the case of a key means: lowercase acts, uppercase opens.** Where a lowercase key and its shifted sibling are two ways of doing one action, the case says *where the work happens*. The lowercase key acts immediately and headlessly and the operator never leaves the TUI; the uppercase key opens an interactive surface — an agent session the terminal is handed over to, or a picker answered before anything happens. So `d` dispatches to the resolved agent where `D` picks the agent first, `r` refines a brief from a typed note where `R` refines it in a session, `n` files a task from a typed line where `N` plans it in a session, and `a` sends a line into the task's session where `A` attaches to it. Taking a line of text inline is not "opening a surface" — a one-line input in the queue is how a lowercase key takes its argument, and the operator's hands never leave the queue to supply it. The convention earns its keep at the moment of pressing: the unshifted key is the one that costs nothing but the keystroke, and the shift is the operator saying they are willing to be taken somewhere. + +The rule binds *pairs*, and only pairs, which is the same line the key line already draws between a shifted sibling and a mere letter-sharer. It therefore has nothing to say about a key whose uppercase is a different action — the cockpit's `c` link documents and `C` cancel a refine, the projects screen's `a` add and `A` archive, the Config screen's `a` add viewer and `A` default agent — nor about an uppercase key with no lowercase sibling at all: the cockpit's `J`/`K` and page keys scroll the card, and the Config screen's `V` picks the default viewer. Those are the exceptions, named here so the convention is not read wider than it is, and none is worth rebinding: the letters they share carry no kinship, and moving a key the operator's fingers already know would buy a consistency nobody reads. What the rule binds instead is the future — a heavier, interactive variant of an existing action takes that action's shifted key rather than a fresh letter, and a new uppercase binding that is neither of those needs a line here saying why. + 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). The mouse is a secondary input over the same keys: a left click on any list row or picker option moves the selection there, exactly as `j`/`k` would, and a second click on an option already under the cursor picks it, as ⏎ would — a click never fires a row's action, and mouse reporting stays on for the whole session, which trades away the terminal's own text selection (shift-drag still bypasses it).