From 3bbcb3c1307a29959600b1e20f1d608981b1c683 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Fri, 14 Aug 2026 22:15:28 +0100 Subject: [PATCH] Scroll the Config screen's agents pane instead of hiding rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pane drew what it could fit and dropped the rest, with a bottom border that looked the same whether it had shown every agent or two of six. Removing the fixed cap made that rarer but not impossible: a short terminal with several agents configured left the rest reachable only by resizing or running `voro agent list` at the shell. It now scrolls with `J`/`K` and the page keys — the cockpit focus card's gesture, taken for the same reason it has there: the pane holds no selection of its own, `j`/`k` on this screen belonging to the viewers list below it, and a second focus is a heavier thing to add than a scroll. The bottom border carries the offset and the keys only when something is hidden, so a pane showing everything still says nothing. Verified at 80x24 and 60x10 in a live TUI against a scratch config of eight agents: every agent reads out, and the border names the overflow. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YareUYKHBQiHfthFE48KDg --- CHANGELOG.md | 10 +++ crates/voro/src/app.rs | 25 +++++++ crates/voro/src/ui.rs | 147 +++++++++++++++++++++++++++++++++++++---- docs/DESIGN.md | 4 +- 4 files changed, 170 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 759f950..27a062a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -272,6 +272,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **The Config screen's agents pane no longer hides agents in silence.** It drew + the rows it could fit and dropped the rest, and its bottom border looked the + same either way — on a short terminal with several agents configured, the ones + past the fold could only be read by resizing the terminal or running `voro + agent list` at the shell. The pane now scrolls with `J`/`K` and the page keys, + the same gesture the cockpit's focus card takes and for the same reason: this + pane has no selection of its own, `j`/`k` on that screen belonging to the + viewers list below it. When there is nothing hidden the border says nothing; + when there is, it carries the overflow and the keys that move it. + - **A capped session's badge now shows the reset time it actually named.** Real cap messages end with an upgrade prompt that mentions a usage limit of its own, and that trailing mention was winning: it carries no time, so every diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index ebb4092..4364ea1 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -576,6 +576,14 @@ pub struct App { /// silently rendering an empty config. pub config_error: Option, pub config_sel: usize, + /// Vertical scroll offset of the Config screen's agents pane (DESIGN.md §9), + /// driven by `J`/`K` and `PgDn`/`PgUp`. The pane carries no selection of its + /// own — `j`/`k` belong to the viewers list below it — so the scroll is the + /// only way past the fold on a terminal too short for every agent. + pub config_agents_scroll: u16, + /// The largest useful `config_agents_scroll` for the pane as last rendered, + /// recorded by `draw_config` for the same reason as `detail_max_scroll`. + pub config_agents_max_scroll: std::cell::Cell, pub mode: Mode, /// Whether the detail views fold the score decomposition (DESIGN.md §7) and @@ -666,6 +674,8 @@ impl App { config_anon_viewer: None, config_error: None, config_sel: 0, + config_agents_scroll: 0, + config_agents_max_scroll: std::cell::Cell::new(0), mode: Mode::Normal, show_score: false, show_history: false, @@ -1226,6 +1236,13 @@ impl App { self.detail_scroll = (self.detail_scroll as i64 + delta).clamp(0, max) as u16; } + /// Scroll the Config screen's agents pane, clamped the same way against the + /// overflow `draw_config` last measured. + fn scroll_config_agents(&mut self, delta: i64) { + let max = self.config_agents_max_scroll.get() as i64; + self.config_agents_scroll = (self.config_agents_scroll as i64 + delta).clamp(0, max) as u16; + } + /// Tab cycles cockpit → tasks → projects → config → cockpit; `alt-1` to /// `alt-4` jump directly (DESIGN.md §9). Until a project is registered the /// ring is the shorter Projects ↔ Config, the cockpit and the browser @@ -3173,6 +3190,14 @@ impl App { KeyCode::Char('d') => self.delete_selected_viewer(), KeyCode::Char('V') => self.open_default_picker(DefaultKind::Viewer), KeyCode::Char('A') => self.open_default_picker(DefaultKind::Agent), + // The agents pane takes the cockpit card's scroll keys for the same + // reason it has there: `j`/`k` are the list's — here the viewers' + // — so the pane below them is driven by the shifted pair and the + // page keys (DESIGN.md §9). + KeyCode::Char('J') => self.scroll_config_agents(1), + KeyCode::Char('K') => self.scroll_config_agents(-1), + KeyCode::PageDown => self.scroll_config_agents(DETAIL_PAGE_STEP), + KeyCode::PageUp => self.scroll_config_agents(-DETAIL_PAGE_STEP), _ => {} } } diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index bb1e0e2..7b16a56 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -1752,19 +1752,34 @@ fn draw_config(frame: &mut Frame, app: &App, hits: &mut HitMap) { } // The pane takes the height its rows need, yielding only what the viewers - // list below needs for a border and a row: that list scrolls with its - // selection while this paragraph does not, so an agent hidden here is the - // more expensive truncation. + // list below needs for a border and a row: both panes scroll, so the split + // is about which one is read whole without a keypress, and that is this one. let agents_h = (agent_lines.len() as u16 + 2).clamp(3, main.height.saturating_sub(3).max(3)); let [agents_area, viewers_area] = Layout::vertical([Constraint::Length(agents_h), Constraint::Min(3)]).areas(main); - let agents = Paragraph::new(agent_lines).block( - Block::default() - .borders(Borders::ALL) - .title("Agents (read-only — * default)"), + // The rows the pane cannot fit are reached with `J`/`K` and the page keys, + // the cockpit card's gesture: the pane carries no selection to scroll with, + // and `j`/`k` here are the viewers list's. The count and the keys ride the + // bottom border, so a pane that is hiding agents says so. + let total = agent_lines.len() as u16; + let block = Block::default() + .borders(Borders::ALL) + .title("Agents (read-only — * default)"); + let max_scroll = total.saturating_sub(agents_area.height.saturating_sub(2)); + app.config_agents_max_scroll.set(max_scroll); + let scroll = app.config_agents_scroll.min(max_scroll); + let block = if max_scroll > 0 { + block.title_bottom( + Line::from(format!(" {scroll}/{max_scroll} ↕ J/K PgDn/PgUp ")).right_aligned(), + ) + } else { + block + }; + frame.render_widget( + Paragraph::new(agent_lines).scroll((scroll, 0)).block(block), + agents_area, ); - frame.render_widget(agents, agents_area); // Viewers: every viewer `open` can run — the built-ins with the user's // tables layered over them, each carrying its provenance like the agents @@ -2004,12 +2019,13 @@ const NEW_KEYS: [(&str, &str); 2] = [ /// 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. +/// screen's `A` share a letter with an unrelated action, `J`/`K` scroll a pane +/// that has no selection to scroll with — the cockpit's card and the Config +/// screen's agents — 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] = [ +const CASE_EXCEPTIONS: [(Screen, &str); 9] = [ (Screen::Cockpit, "C"), (Screen::Cockpit, "J"), (Screen::Cockpit, "K"), @@ -2017,6 +2033,8 @@ const CASE_EXCEPTIONS: [(Screen, &str); 7] = [ (Screen::Projects, "A"), (Screen::Config, "V"), (Screen::Config, "A"), + (Screen::Config, "J"), + (Screen::Config, "K"), ]; const MESSAGE_KEYS: [(&str, &str); 2] = [ ("a", "message the task's session, headless"), @@ -2158,6 +2176,8 @@ fn key_map(screen: Screen, no_projects: bool) -> Vec { "Navigation", vec![ ("j/k", "move the selection"), + ("J/K", "scroll the agents pane"), + ("PgUp/PgDn", "page the agents pane"), ("?", "this key map"), ("q", "quit"), ], @@ -2754,13 +2774,112 @@ mod tests { assert!(rendered.contains(&format!("{{model}}: m{n}")), "{rendered}"); } // The viewers list keeps a row of its own; what it gave up it can still - // scroll to, which the agents paragraph could not. + // scroll to. assert!(rendered.contains("Viewers"), "{rendered}"); assert!(rendered.contains("code -n {path}"), "{rendered}"); std::fs::remove_dir_all(&dir).unwrap(); } + /// The bug (task #450): where the pane cannot fit its rows, the ones past + /// the fold were simply not drawn and nothing said so. Now the bottom + /// border carries the overflow and the keys that move it, and `J` walks the + /// hidden agents into view — on a terminal no larger than 80x24. + #[test] + fn config_agents_pane_scrolls_to_the_agents_it_cannot_fit() { + use crate::app::App; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::crossterm::event::{KeyCode, KeyEvent}; + use voro_core::Store; + + let dir = std::env::temp_dir().join(format!( + "voro-ui-config-scroll-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let agents_path = dir.join("voro.toml"); + std::fs::create_dir_all(&dir).unwrap(); + let mut toml = String::from("[viewers.zed]\ncmd = \"zed {path}\"\n"); + for n in 1..=6 { + toml.push_str(&format!( + "\n[agents.mine{n}]\ndispatch = \"mine{n} run {{prompt_file}} --model {{model}}\"\n\ + model = \"m{n}\"\n" + )); + } + std::fs::write(&agents_path, toml).unwrap(); + + let store = Store::open_in_memory().unwrap(); + let ctx = crate::dispatch::DispatchCtx { + db_path: dir.join("voro.db"), + agents_path, + runtime_dir: dir.join("sessions"), + ref_capture_timeout: std::time::Duration::ZERO, + message_grace: std::time::Duration::from_millis(300), + }; + let mut app = App::new(store, ctx).unwrap(); + alt_screen(&mut app, '4'); + + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + let render = |terminal: &mut Terminal, app: &App| { + terminal + .draw(|f| { + draw(f, app); + }) + .unwrap(); + terminal + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect::() + }; + + let rendered = render(&mut terminal, &app); + let hidden = app.config_agents_max_scroll.get(); + assert!( + hidden > 0, + "eight agents should overflow an 80x24 pane:\n{rendered}" + ); + assert!( + rendered.contains(&format!("0/{hidden} ↕ J/K PgDn/PgUp")), + "the pane hides rows without saying so:\n{rendered}" + ); + + // Every agent is reachable: walk to the bottom a row at a time and the + // last one — the one the fold ate — is on screen. + let last = app + .config_agents + .last() + .expect("agents are configured") + .name + .clone(); + assert!(!rendered.contains(&last), "{rendered}"); + for _ in 0..hidden { + app.on_key(KeyEvent::from(KeyCode::Char('J'))); + } + assert_eq!(app.config_agents_scroll, hidden, "J clamps at the bottom"); + let rendered = render(&mut terminal, &app); + assert!(rendered.contains(&last), "{rendered}"); + assert!( + rendered.contains(&format!("{hidden}/{hidden} ↕ J/K PgDn/PgUp")), + "{rendered}" + ); + + // `K` walks back, and the viewers list keeps its own `j`/`k`. + app.on_key(KeyEvent::from(KeyCode::PageUp)); + assert!(app.config_agents_scroll < hidden); + let before = app.config_sel; + app.on_key(KeyEvent::from(KeyCode::Char('j'))); + assert_ne!(app.config_sel, before, "j still moves the viewer selection"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + fn row(state: TaskState, blockers: Vec) -> TaskRow { TaskRow { task: Task { diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1ee47c7..70ffab7 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -413,7 +413,7 @@ A hand-off (§6) rides it the same way — `⏳ waiting`, elapsed from the hand- The cockpit is where the TUI opens, with one exception: a database with no projects registered opens on the projects screen instead, because that is where the first step is — nothing can be created until a project exists, and the cockpit has nothing to show until one does. The check runs once, at startup, against the project list the app already loads; every screen change after that is a key the operator pressed, so a refresh, a poll, or deleting the last project never moves them. **That landing is held by a gate rather than left to the first keypress:** until a project exists the TUI is a two-screen tool, Projects and Config, and the cockpit and the task browser cannot be entered at all. Pointing the operator at Projects and then letting Tab walk them straight off it bought nothing — the cockpit and the browser each had a full screen of content whose entire message was "not here", and two screens that exist only to say that are worse than two screens that cannot be reached. Config stays reachable throughout because it edits the `voro.toml` viewers and agents, which needs no project and is a legitimate place to be before registering one; the gate is about screens with nothing to show, not about a rule that nothing may be done first. Because the gate is expressed as a shorter Tab ring (Projects ↔ Config) and the projects screen binds no screen jumps, the only place a refusal can fire is the alt-digit jump to the cockpit or the browser, which no-ops with a status line naming the route to a project — the same shape as `n`'s own zero-project refusal. The `?` key map follows suit and stops advertising `alt-1` and `alt-2` while the gate holds, since a map that listed them would be promising a refusal. Adding the first project does not move the operator off the projects screen, but every screen is reachable from the next keypress on. The gate also settles what the two empty states used to say: with the cockpit and the browser unreachable without a project, each has exactly one case left to explain — a drained queue and a project with no tasks — and both point at `n`. The create keys ask *which* project only when there is a choice to be made, and they offer only projects that can take the task: an archived project refuses new work (§5), so it is dropped from the picker rather than listed there to fail — late, in the `$EDITOR` and planning flows, after the operator has already written the task out. What remains is ordered weightiest first, each row carrying the weight it sorts on, because project weight is the one per-project priority Voro holds (§7) and it is what the operator sets every morning, where alphabetical order says nothing about which project this week's work is in. A parked project stays on the list and simply sorts last, weight 0 being a snooze rather than a retirement. So a single unarchived project beside archived ones skips the picker entirely and creates straight into the live one, and a store whose every project is archived opens no picker at all, refusing with a status line pointing at the projects screen — the same shape as the zero-project refusal above. -Beyond the cockpit, the TUI cycles (Tab, or `alt-1`–`alt-4`, subject to the gate above while no project is registered) through three further full-screen views: the **task browser**, the **projects screen** (weights, archive, and the per-project viewer), 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. An agent occupies more than a row there: under its name, provenance and verb list sit dim continuation lines carrying the dispatch command it runs and, where it names one, what `{model}` resolves to. The model map is a line of its own rather than a tail on the name row because that row already lists every optional verb the agent defines (§8), which is long enough that the built-in `claude` ran past an ordinary terminal's width and clipped the annotation off the end — and the annotation exists precisely to be read beside the placeholder in the command above it, so it is the half that cannot be allowed to fall off. The pane then sizes to the rows it has rather than to a fixed cap, yielding height only where the viewers list below would otherwise lose its last row, since that list scrolls with its selection and this one does not. DB-backed configuration (projects, weights, viewers) stays on the projects screen; the Config screen is the voro.toml view. The projects screen's viewer 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. +Beyond the cockpit, the TUI cycles (Tab, or `alt-1`–`alt-4`, subject to the gate above while no project is registered) through three further full-screen views: the **task browser**, the **projects screen** (weights, archive, and the per-project viewer), 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. An agent occupies more than a row there: under its name, provenance and verb list sit dim continuation lines carrying the dispatch command it runs and, where it names one, what `{model}` resolves to. The model map is a line of its own rather than a tail on the name row because that row already lists every optional verb the agent defines (§8), which is long enough that the built-in `claude` ran past an ordinary terminal's width and clipped the annotation off the end — and the annotation exists precisely to be read beside the placeholder in the command above it, so it is the half that cannot be allowed to fall off. The pane then sizes to the rows it has rather than to a fixed cap, yielding height only where the viewers list below would otherwise lose its last row. Where even that is not enough — a short terminal, several agents — the pane **scrolls** rather than silently dropping what falls past its border, which is the failure it had: an operator on a 60x10 terminal saw two of six agents and nothing on the screen said the other four existed. It scrolls with `J`/`K` and the page keys, the cockpit card's gesture and for the same reason: the pane carries no selection of its own, `j`/`k` on this screen belonging to the viewers list below, and a second selection is a heavier thing to add to a screen than a scroll. Like the card it advertises the scroll only when there is one, on its bottom border, so a pane holding everything says nothing and a pane hiding rows says how many and which keys move them. That the *viewers* list keeps the height it needs is now a question of which pane is read whole without a keypress rather than which one can be read at all. DB-backed configuration (projects, weights, viewers) stays on the projects screen; the Config screen is the voro.toml view. The projects screen's viewer 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. **A bare digit sets the number on the selected row, and screen jumps carry the modifier.** The digit's meaning follows the selection rather than the screen: `0`–`3` set the selected *task's* priority on the cockpit, in the task browser, and in the browser's detail popup, and `0`–`5` set the selected *project's* weight on the projects screen. Screen switching is the thing that gave way, because it is not the frequent act — Tab already cycles all four screens — while re-prioritising is: the daily move is "this project matters more today" and "this task matters more than that one", and both should cost one keystroke on the row already under the cursor. The rule also settles a collision the two meanings had while they shared the digits, where `1` pressed on the projects screen to reach the cockpit silently re-weighted the selected project instead, reordering every project's tasks in the queue (§7) with nothing said and nothing to undo it. The modifier is `alt` rather than `shift` because shift cannot serve: crossterm reports no SHIFT modifier for a digit — a shifted digit arrives as its bare symbol — those symbols are layout-dependent (`shift-2` is `@` on a US keyboard and `"` on a UK one), and `!`, shift-1, is already the deep toggle. `ctrl-` produces no distinct sequence in a legacy terminal, so `alt-1`–`alt-4` are the jumps, layout-independent and testable. Some terminal emulators claim `alt-` for themselves, and where they do the jump simply never arrives and Tab still cycles; nothing else is bound to the chord, so nothing changes hands. A digit that resolves to no task — a collapsed proposal digest, which names no single task, or an empty queue — says so on the status line rather than doing nothing quietly, as does a `4` or `5` pressed on a task, priority stopping at P3 where weight runs to 5. @@ -423,7 +423,7 @@ Beyond the cockpit, the TUI cycles (Tab, or `alt-1`–`alt-4`, subject to the ga **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 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: `J`/`K` and the page keys scroll the pane the screen's selection cannot reach — the cockpit's focus card, the Config screen's agents — which is one binding wearing one meaning twice rather than two, and is why the second use took those letters rather than fresh ones, 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 a task by typing one line and letting a background agent expand it into a proposal (§8's quick propose, on the default key, as the case convention above asks) — or plan one interactively with an agent (§8's planning sessions, on the sibling key), or write it out by hand in `$EDITOR` (title, body, priority, deps, agent override via frontmatter or a form), which stays the only path that sets all of those at creation time; edit a task in `$EDITOR`; 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).