Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 61 additions & 11 deletions crates/voro/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
];

Expand Down Expand Up @@ -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::<Vec<_>>())
.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).
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading