From 396cac0919e63c9aa9f3632452572dcf2d299cb5 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Fri, 14 Aug 2026 22:16:18 +0100 Subject: [PATCH 1/2] Name a non-default database in the TUI footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing on screen said which store the TUI had opened. The only place it was ever said is the stderr warning a target/ build prints when it declines an inherited VORO_DB, which the alternate screen paints over before anyone reads it — so an operator following the verify skill could sit on the shared dev store believing it was a scratch one. The footer now carries the store's path, right-aligned against the key line the way the header right-aligns its counts, dim, on all four screens. The condition is the comparison against Store::production_db_path() rather than default_db_path(), which is the crux: the default for a target/ build *is* dev.db, so an indicator keyed on it would fall silent in exactly the case that raises the question. It is the rule db_flag and seed_verb already follow. The operator's own store shows nothing, the reserved width is zero, and the key line has the row entire. The status message still owns the row alone and wraps; the indicator is suppressed rather than right-aligned against moving text. The region never grows a line for it. Verified with cargo test/clippy/fmt and live in tmux: a scratch store names itself on every screen, the same binary with no --db shows ~/.local/share/voro/ dev.db, a store at the production path (XDG_DATA_HOME redirected) shows nothing, and a status message suppresses the indicator. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C8BCU78Axcxsf3sM7s9VEP --- crates/voro/src/app.rs | 6 ++ crates/voro/src/ui.rs | 174 ++++++++++++++++++++++++++++++++++++++++- docs/DESIGN.md | 4 +- 3 files changed, 180 insertions(+), 4 deletions(-) diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index ebb4092..23723cc 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -706,6 +706,12 @@ impl App { &self.dispatch_ctx.agents_path } + /// The database this run opened (DESIGN.md §5), for the footer to name it + /// when it is not the operator's own store (§9). + pub fn db_path(&self) -> &std::path::Path { + &self.dispatch_ctx.db_path + } + /// Refresh if another process has committed since the last check. Cheap /// enough to call every poll tick; `PRAGMA data_version` ignores our own /// writes, so this fires only on genuinely external changes. diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index bb1e0e2..c2f4185 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -1,3 +1,5 @@ +use std::path::{Path, PathBuf}; + use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Margin, Rect}; use ratatui::style::{Color, Modifier, Style, Stylize}; @@ -6,7 +8,7 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragra use voro_core::{ ActionRow, CapReading, CompletionReport, DepKind, DepRef, DigestRow, EffectiveScore, Event, - QueueRow, ScoreBreakdown, Session, SessionOutcome, StateCounts, TaskState, + QueueRow, ScoreBreakdown, Session, SessionOutcome, StateCounts, Store, TaskState, }; use crate::app::{ @@ -1860,7 +1862,10 @@ fn wrap_status(msg: &str, width: u16) -> Vec { } fn draw_status(frame: &mut Frame, app: &App, area: Rect) { - // A red status message overrides the key line, as before. + // A red status message overrides the key line, as before, and owns the row + // whole — the indicator below would be competing with wrapped text for a + // right margin that moves (DESIGN.md §9). The message is gone on the next + // keystroke; the store is not going anywhere. if let Some(msg) = &app.status { let lines: Vec = wrap_status(msg, area.width) .into_iter() @@ -1877,9 +1882,78 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { spans.push(Span::styled(key, Style::new().bold())); spans.push(Span::styled(format!(" {label}"), Style::new().dim())); } - frame.render_widget(Line::from(spans), area); + // The store, right-aligned the way the header right-aligns its counts, so + // the key line keeps the left margin it has always started at. On the + // operator's own store there is no indicator, the reserved width is zero, + // and the key line gets the row back byte for byte. + let indicator = db_indicator(app.db_path(), area.width); + let reserved = indicator + .as_deref() + .map_or(0, |text| Span::raw(text).width() as u16 + 1); + let [keys, store] = + Layout::horizontal([Constraint::Min(0), Constraint::Length(reserved)]).areas(area); + frame.render_widget(Line::from(spans), keys); + if let Some(text) = indicator { + frame.render_widget( + Line::from(vec![Span::raw(" "), Span::styled(text, Style::new().dim())]), + store, + ); + } +} + +/// The store to name in the footer, or `None` when it is the operator's own and +/// there is nothing to say (DESIGN.md §9). +/// +/// The comparison is against [`Store::production_db_path`] rather than +/// [`Store::default_db_path`], which is the crux of it: the default is `dev.db` +/// for a `target/` build, so an indicator keyed on it would stay silent on a dev +/// store — the very case that asks the question. This is the rule dispatch's +/// `--db` flag and `voro seed` already follow (§5). +fn db_indicator(db_path: &Path, width: u16) -> Option { + if db_path == Store::production_db_path() { + return None; + } + let home = std::env::var_os("HOME").map(PathBuf::from); + Some(shorten_store_path(db_path, home.as_deref(), width)) +} + +/// A store path cut down to what identifies it inside `width` columns of footer: +/// `~` for the home directory, and — if it is still longer than about a third of +/// the row — leading directories dropped for a `…`, since the filename and its +/// parent are the half that names the store and the path to them is the half +/// that does not. Components go whole, so what is left is still a path; a +/// filename too long even on its own is cut mid-word as a last resort. +fn shorten_store_path(path: &Path, home: Option<&Path>, width: u16) -> String { + let text = match home + .filter(|home| !home.as_os_str().is_empty()) + .and_then(|home| path.strip_prefix(home).ok()) + { + Some(rest) => format!("~/{}", rest.display()), + None => path.display().to_string(), + }; + let budget = usize::from(width / 3) + .max(MIN_STORE_INDICATOR) + .min(usize::from(width)); + let len = text.chars().count(); + if len <= budget || budget == 0 { + return text; + } + let parts: Vec<&str> = text.split('/').collect(); + for first in 1..parts.len() { + let candidate = format!("…/{}", parts[first..].join("/")); + if candidate.chars().count() <= budget { + return candidate; + } + } + let tail: String = text.chars().skip(len - (budget - 1)).collect(); + format!("…{tail}") } +/// The narrowest the store indicator is allowed to get before it stops shrinking +/// with the row — narrower than this and a truncated path is little more than +/// its own filename, which no longer says which store it is. +const MIN_STORE_INDICATOR: usize = 16; + /// Whether the selection is a brief refine can still rewrite — a proposal or a /// ready task (DESIGN.md §6). fn selection_is_refinable(app: &App) -> bool { @@ -2616,6 +2690,100 @@ mod tests { assert_eq!(status_height(&app, Rect::new(0, 0, 40, 3)), 1); } + /// The footer names the store only when it is not the operator's own + /// (DESIGN.md §9), and the comparison is against the production path rather + /// than the default one — so a dev build, whose default *is* `dev.db`, says + /// so instead of staying silent (§5). + #[test] + fn the_footer_names_every_store_but_the_operator_s() { + assert_eq!(db_indicator(&Store::production_db_path(), 110), None); + assert!( + db_indicator(&Store::dev_db_path(), 110).is_some_and(|text| text.ends_with("dev.db")), + "a dev build has to name dev.db, got {:?}", + db_indicator(&Store::dev_db_path(), 110) + ); + assert_eq!( + db_indicator(Path::new("/tmp/scratch/voro.db"), 110), + Some("/tmp/scratch/voro.db".to_string()) + ); + } + + /// A store under the home directory is shown against `~`, which is both + /// shorter and how the operator refers to it. + #[test] + fn a_store_under_home_is_shown_against_a_tilde() { + assert_eq!( + shorten_store_path( + Path::new("/home/op/.local/share/voro/dev.db"), + Some(Path::new("/home/op")), + 110 + ), + "~/.local/share/voro/dev.db" + ); + // No home to compare against leaves the path as it is. + assert_eq!( + shorten_store_path(Path::new("/srv/voro/voro.db"), None, 110), + "/srv/voro/voro.db" + ); + } + + /// Past about a third of the row the path is cut from the *left*: the + /// filename and its parent name the store, the leading directories do not. + #[test] + fn a_long_store_path_is_truncated_from_the_left() { + let long = Path::new("/home/op/very/deeply/nested/scratch/area/voro.db"); + let text = shorten_store_path(long, Some(Path::new("/home/op")), 60); + assert_eq!(text, "…/area/voro.db"); + assert!(text.chars().count() <= 20, "{text}"); + + // It stops shrinking with the row while the parent still fits. + assert_eq!( + shorten_store_path(long, Some(Path::new("/home/op")), 24), + "…/area/voro.db" + ); + // A filename with nowhere left to give is cut mid-word rather than + // pushed past the budget. + assert_eq!( + shorten_store_path(Path::new("/tmp/a-very-long-store-name.db"), None, 30), + "…g-store-name.db" + ); + } + + /// End-to-end: the indicator reaches the footer of a drawn screen, dim and + /// right of the key line, without costing the region a row. + #[test] + fn the_footer_carries_the_store_on_screen() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let mut app = app_with_status("", 1, 0); + app.status = None; + let mut terminal = Terminal::new(TestBackend::new(110, 24)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); + let text = screen_text(&terminal); + // `dummy_ctx`'s store is not the operator's, so the row names it. + assert!(text.contains("/nonexistent/voro.db"), "{text}"); + assert!( + text.contains("⏎ act · d/D dispatch"), + "the key line still starts the row: {text}" + ); + + // A message owns the row alone; nothing competes with it. + app.status = Some("task 9 has no session on record".into()); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); + let text = screen_text(&terminal); + assert!(text.contains("task 9 has no session on record"), "{text}"); + assert!(!text.contains("/nonexistent/voro.db"), "{text}"); + } + /// End-to-end: the Config screen renders the read-only agents (with the /// default marked) over the editable named viewers, drawn through the real /// screen draw path (DESIGN.md §5). diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1ee47c7..341be10 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -55,7 +55,7 @@ The **cockpit** is the interface, and it is built first: a ratatui TUI rendering A single owned SQLite database, not a wrapper over a per-project tool: the ready-work detection Voro needs is one SQL query (below), without the git-native sync and multi-agent locking a per-repo tool would bring for contention problems a single-operator tool does not have. The dependency taxonomy and the discovered-from convention below are lifted directly from beads' design. -**Which database a build opens.** A build running out of a Cargo `target/` directory opens `dev.db` beside the operator's store rather than `voro.db` itself, seeded on first use with the fixture in `voro-core`'s `seed` module. `VORO_DB` is declined by such a build for the same reason: dispatch exports it so a session's return path finds the store its dispatcher was on (§8), which makes it a value a process *inherits* rather than one it asks for, and every agent working in a worktree therefore has the operator's database named in its environment. Naming a store with `--db` is deliberate and is honoured; inheriting one is not, and the rule is about that distinction rather than about which path the variable holds. +**Which database a build opens.** A build running out of a Cargo `target/` directory opens `dev.db` beside the operator's store rather than `voro.db` itself, seeded on first use with the fixture in `voro-core`'s `seed` module. `VORO_DB` is declined by such a build for the same reason: dispatch exports it so a session's return path finds the store its dispatcher was on (§8), which makes it a value a process *inherits* rather than one it asks for, and every agent working in a worktree therefore has the operator's database named in its environment. Naming a store with `--db` is deliberate and is honoured; inheriting one is not, and the rule is about that distinction rather than about which path the variable holds. Whichever of these a run lands on, the TUI names it in the footer unless it is the operator's own store (§9), keyed on that store's path rather than on the default this paragraph describes. This is ergonomics, not protection, and the difference matters. The check is on where the running executable lives, and `cargo install --path` builds a working checkout — unreleased migrations and all — into an ordinary install location, where it reads as installed. So the default-chooser has a blind spot on precisely the route that is easiest to take, which is survivable only because nothing depends on it: what protects the schema is the journal and the counter below, which reason about what a database actually contains. A default-chooser may have blind spots; a guard may not. @@ -421,6 +421,8 @@ Beyond the cockpit, the TUI cycles (Tab, or `alt-1`–`alt-4`, subject to the ga **The same row is where Voro answers back, and it grows to fit what it has to say.** A status message — a refusal, or the summary of something that just happened — takes the key line's row until the next keystroke, and it is *wrapped* across as many rows as it needs rather than truncated at the pane width. The reason is a property of the messages themselves: Voro's refusals are written to end on the way out, naming the key to press instead (§8's `g` on a checkout that is not GitHub points at `o`, and does it in the caller's own idiom), so a line cut at the right margin loses precisely the half worth reading, and loses it at whatever width the operator's terminal happens to be. The region is therefore sized from the wrapped message before the screen's panes divide the rows, and the panes above give up the space — an error the operator is being asked to act on outranks a row of the list they were browsing. It stops growing at half the screen, since a message that cannot be said in half a terminal is not going to be fixed by burying the lists under it, and it is one row again — for the key line, and for the short messages that fit — the moment there is nothing long to say. +**The right end of that row says which store is open, and only when it is not the operator's own.** Nothing else on screen answers "which database am I looking at?" — the one place it was ever said is the startup warning a `target/` build prints when it declines an inherited `VORO_DB` (§5), and the alternate screen paints over it before anyone reads it. So the footer carries the store's path, right-aligned against the key line the way the header right-aligns its counts, dim, on every screen rather than on the cockpit alone, since the question is not cockpit-specific. What decides whether it appears is the comparison against the *production* path (§5) rather than against whichever store the running binary defaults to, and that is the whole of the point: the default for a `target/` build **is** `dev.db`, so an indicator keyed on it would fall silent in exactly the case that raises the question. An installed `voro` on the operator's store therefore shows nothing at all, and the key line has the row entire, exactly as before; a dev build says `dev.db`, and a run under `--db` or an honoured `VORO_DB` says where it landed. It is the rule dispatch's `--db` flag and `voro seed`'s refusal already follow, for the same reason. The path is shortened to fit — `~` for the home directory, and past about a third of the row leading directories give way to a `…`, since the filename and its parent are the half that names the store — and the row never grows a line for it: an indicator that costs a row whenever it is absent would have to earn its place, and this one earns its place by costing nothing. When a status message is up it owns the row alone and the indicator is suppressed rather than right-aligned against wrapped text; the message is gone on the next keystroke and the store is not going anywhere. + **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. From 9413bce044915c5143b6264373e36aab77641ff6 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Fri, 14 Aug 2026 22:58:55 +0100 Subject: [PATCH 2/2] Size the footer's store indicator from what the key line leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The indicator claimed its width from the row and handed the key line the remainder, which is backwards: the key line has a fixed, documented eleven-slot budget (DESIGN.md §9) and the indicator carries a path of unbounded length. At 110 columns a deep scratch path pushed `? keys · tab tasks · q quit` off the row — `?` being the key that would have said what the line dropped — while every key line the app can produce fits in 110 columns unaided. So draw_status measures the key line first and gives the indicator what is left over. shorten_store_path takes that leftover as its budget rather than a third of the row, which retires MIN_STORE_INDICATOR, and returns an Option: the ladder degrades from the full path through `~`, through leading directories surrendered whole for a `…`, to the bare filename, and then to nothing. The mid-word cut is gone — `…g-store-name.db` names no store, and those columns are the key line's to have back. Absence is not neutral here, since an empty right margin means the operator's own store, so vanishing is the last rung and not the first; a filename longer than the leftover is the one case that loses information, and it is preferable to spending the line's recovery keys on half a name. key_hints and hint_candidates are untouched: below about 107 columns the key line can outgrow the row with no indicator on it at all, and that clip predates this branch. Verified with cargo test/clippy/fmt and live in tmux at 110 columns on a deep scratch path: all four screens keep every slot through `q quit`, the Config screen — the longest line here — degrading to `voro.db`; widening to 130 and 160 columns brings the path back progressively; a no---db dev build shows `…/share/voro/dev.db`; a store at the production path still shows nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C8BCU78Axcxsf3sM7s9VEP --- crates/voro/src/ui.rs | 199 ++++++++++++++++++++++++++++++++---------- docs/DESIGN.md | 2 +- 2 files changed, 152 insertions(+), 49 deletions(-) diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index c2f4185..ce8def8 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -1883,16 +1883,24 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { spans.push(Span::styled(format!(" {label}"), Style::new().dim())); } // The store, right-aligned the way the header right-aligns its counts, so - // the key line keeps the left margin it has always started at. On the - // operator's own store there is no indicator, the reserved width is zero, - // and the key line gets the row back byte for byte. - let indicator = db_indicator(app.db_path(), area.width); + // the key line keeps the left margin it has always started at. The line is + // measured first and the indicator takes what is left over: the key line's + // slot budget is fixed and documented (DESIGN.md §9), a store path's length + // is not, so the occupant that cannot be bounded is the one that yields. On + // the operator's own store there is no indicator, the reserved width is + // zero, and the key line gets the row back byte for byte. + let keys_line = Line::from(spans); + let leftover = area + .width + .saturating_sub(keys_line.width() as u16) + .saturating_sub(1); + let indicator = db_indicator(app.db_path(), leftover); let reserved = indicator .as_deref() .map_or(0, |text| Span::raw(text).width() as u16 + 1); let [keys, store] = Layout::horizontal([Constraint::Min(0), Constraint::Length(reserved)]).areas(area); - frame.render_widget(Line::from(spans), keys); + frame.render_widget(keys_line, keys); if let Some(text) = indicator { frame.render_widget( Line::from(vec![Span::raw(" "), Span::styled(text, Style::new().dim())]), @@ -1901,29 +1909,33 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { } } -/// The store to name in the footer, or `None` when it is the operator's own and -/// there is nothing to say (DESIGN.md §9). +/// The store to name in the footer within `budget` columns — the ones the key +/// line left — or `None` when it is the operator's own and there is nothing to +/// say, or when not even the store's filename fits (DESIGN.md §9). /// /// The comparison is against [`Store::production_db_path`] rather than /// [`Store::default_db_path`], which is the crux of it: the default is `dev.db` /// for a `target/` build, so an indicator keyed on it would stay silent on a dev /// store — the very case that asks the question. This is the rule dispatch's /// `--db` flag and `voro seed` already follow (§5). -fn db_indicator(db_path: &Path, width: u16) -> Option { +fn db_indicator(db_path: &Path, budget: u16) -> Option { if db_path == Store::production_db_path() { return None; } let home = std::env::var_os("HOME").map(PathBuf::from); - Some(shorten_store_path(db_path, home.as_deref(), width)) + shorten_store_path(db_path, home.as_deref(), budget) } -/// A store path cut down to what identifies it inside `width` columns of footer: -/// `~` for the home directory, and — if it is still longer than about a third of -/// the row — leading directories dropped for a `…`, since the filename and its -/// parent are the half that names the store and the path to them is the half -/// that does not. Components go whole, so what is left is still a path; a -/// filename too long even on its own is cut mid-word as a last resort. -fn shorten_store_path(path: &Path, home: Option<&Path>, width: u16) -> String { +/// A store path cut down to what identifies it inside `budget` columns: `~` for +/// the home directory, then leading directories given up whole for a `…`, since +/// the filename and its parent are the half that names the store and the path to +/// them is the half that does not. The ladder ends at the bare filename — +/// `dev.db` says "not your store" in six columns — and below that at `None`, +/// because a fragment of a name identifies nothing and the columns are the key +/// line's to have back. Absence is not neutral here: an empty right margin means +/// the operator's own store, so the indicator would rather say nothing than say +/// something unreadable. +fn shorten_store_path(path: &Path, home: Option<&Path>, budget: u16) -> Option { let text = match home .filter(|home| !home.as_os_str().is_empty()) .and_then(|home| path.strip_prefix(home).ok()) @@ -1931,29 +1943,21 @@ fn shorten_store_path(path: &Path, home: Option<&Path>, width: u16) -> String { Some(rest) => format!("~/{}", rest.display()), None => path.display().to_string(), }; - let budget = usize::from(width / 3) - .max(MIN_STORE_INDICATOR) - .min(usize::from(width)); - let len = text.chars().count(); - if len <= budget || budget == 0 { - return text; + let budget = usize::from(budget); + if text.chars().count() <= budget { + return Some(text); } let parts: Vec<&str> = text.split('/').collect(); for first in 1..parts.len() { let candidate = format!("…/{}", parts[first..].join("/")); if candidate.chars().count() <= budget { - return candidate; + return Some(candidate); } } - let tail: String = text.chars().skip(len - (budget - 1)).collect(); - format!("…{tail}") + let name = parts.last().copied().unwrap_or_default(); + (!name.is_empty() && name.chars().count() <= budget).then(|| name.to_string()) } -/// The narrowest the store indicator is allowed to get before it stops shrinking -/// with the row — narrower than this and a truncated path is little more than -/// its own filename, which no longer says which store it is. -const MIN_STORE_INDICATOR: usize = 16; - /// Whether the selection is a brief refine can still rewrite — a proposal or a /// ready task (DESIGN.md §6). fn selection_is_refinable(app: &App) -> bool { @@ -2717,35 +2721,62 @@ mod tests { Path::new("/home/op/.local/share/voro/dev.db"), Some(Path::new("/home/op")), 110 - ), - "~/.local/share/voro/dev.db" + ) + .as_deref(), + Some("~/.local/share/voro/dev.db") ); // No home to compare against leaves the path as it is. assert_eq!( - shorten_store_path(Path::new("/srv/voro/voro.db"), None, 110), - "/srv/voro/voro.db" + shorten_store_path(Path::new("/srv/voro/voro.db"), None, 110).as_deref(), + Some("/srv/voro/voro.db") ); } - /// Past about a third of the row the path is cut from the *left*: the - /// filename and its parent name the store, the leading directories do not. + /// The budget is whatever the key line did not want, so the same store + /// renders whole beside a short line and gives up its leading directories + /// beside a long one. #[test] - fn a_long_store_path_is_truncated_from_the_left() { + fn the_store_shortens_into_the_columns_the_key_line_left() { let long = Path::new("/home/op/very/deeply/nested/scratch/area/voro.db"); - let text = shorten_store_path(long, Some(Path::new("/home/op")), 60); - assert_eq!(text, "…/area/voro.db"); - assert!(text.chars().count() <= 20, "{text}"); + let home = Some(Path::new("/home/op")); + assert_eq!( + shorten_store_path(long, home, 41).as_deref(), + Some("~/very/deeply/nested/scratch/area/voro.db") + ); + assert_eq!( + shorten_store_path(long, home, 40).as_deref(), + Some("…/deeply/nested/scratch/area/voro.db") + ); + assert_eq!( + shorten_store_path(long, home, 20).as_deref(), + Some("…/area/voro.db") + ); + } - // It stops shrinking with the row while the parent still fits. + /// The bottom of the ladder: the bare filename, which still says "not your + /// store", and then nothing at all — a fragment of a name identifies no + /// store, and an empty right margin is the key line's to have back. + #[test] + fn a_store_with_no_room_left_shows_its_filename_or_nothing() { + let long = Path::new("/home/op/very/deeply/nested/scratch/area/voro.db"); + let home = Some(Path::new("/home/op")); assert_eq!( - shorten_store_path(long, Some(Path::new("/home/op")), 24), - "…/area/voro.db" + shorten_store_path(long, home, 7).as_deref(), + Some("voro.db"), + "the filename alone fits and is worth saying" ); - // A filename with nowhere left to give is cut mid-word rather than - // pushed past the budget. + // One column more and the `…` marking what was dropped fits too. assert_eq!( - shorten_store_path(Path::new("/tmp/a-very-long-store-name.db"), None, 30), - "…g-store-name.db" + shorten_store_path(long, home, 9).as_deref(), + Some("…/voro.db") + ); + assert_eq!(shorten_store_path(long, home, 6), None); + assert_eq!(shorten_store_path(long, home, 0), None); + // A name too long for the row is never cut mid-word: `…g-store-name.db` + // names nothing. + assert_eq!( + shorten_store_path(Path::new("/tmp/a-very-long-store-name.db"), None, 20), + None ); } @@ -2758,7 +2789,9 @@ mod tests { let mut app = app_with_status("", 1, 0); app.status = None; - let mut terminal = Terminal::new(TestBackend::new(110, 24)).unwrap(); + // Wide enough that the cockpit's key line and `dummy_ctx`'s store both + // fit: the indicator never takes a column the line wanted. + let mut terminal = Terminal::new(TestBackend::new(140, 24)).unwrap(); terminal .draw(|f| { draw(f, &app); @@ -2771,6 +2804,7 @@ mod tests { text.contains("⏎ act · d/D dispatch"), "the key line still starts the row: {text}" ); + assert!(text.contains("q quit"), "and still ends it: {text}"); // A message owns the row alone; nothing competes with it. app.status = Some("task 9 has no session on record".into()); @@ -2784,6 +2818,75 @@ mod tests { assert!(!text.contains("/nonexistent/voro.db"), "{text}"); } + /// The row the key line spends its whole budget on — a `review` task with a + /// branch, a PR and a session, on both screens that show it — keeps every + /// slot at an ordinary width. Whatever the store does with what is left, it + /// may not cost the line a key. + #[test] + fn the_widest_key_line_keeps_its_last_slots() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let mut app = app_in_review_with_everything(); + for (screen, first) in [(Screen::Cockpit, "⏎ review"), (Screen::Tasks, "⏎ view")] { + app.screen = screen; + let mut terminal = Terminal::new(TestBackend::new(110, 24)).unwrap(); + terminal + .draw(|f| { + draw(f, &app); + }) + .unwrap(); + let text = screen_text(&terminal); + assert!( + text.contains(first), + "{screen:?} lost its first slot: {text}" + ); + for slot in ["o open", "g PR", "? keys", "q quit"] { + assert!(text.contains(slot), "{screen:?} lost `{slot}`: {text}"); + } + } + } + + /// A `review` task carrying everything the key line can advertise: a branch + /// (`o`), a pull request (`g`), and a session to message (`a/A`). + fn app_in_review_with_everything() -> crate::app::App { + use voro_core::{Action, NewTask, Store}; + + let mut store = Store::open_in_memory().unwrap(); + let p = store.create_project("voro", "/tmp/voro").unwrap(); + let task = store + .create_task(NewTask { + project_id: p.id, + repo_id: None, + title: "a task under review".into(), + body: String::new(), + priority: Priority::P2, + state: TaskState::Ready, + agent: None, + human: false, + deep: false, + }) + .unwrap(); + store + .record_dispatch(task.id, "claude", None, LivenessSource::Listing, None) + .unwrap(); + store.apply(task.id, Action::Complete(None)).unwrap(); + store.set_branch(task.id, Some("feat/x")).unwrap(); + store + .set_pr(task.id, Some("https://github.com/o/r/pull/1")) + .unwrap(); + + let mut app = crate::app::App::new( + store, + crate::dispatch::DispatchCtx::without_config(std::path::Path::new( + "/home/op/deeply/nested/scratch/store/voro.db", + )), + ) + .unwrap(); + app.status = None; + app + } + /// End-to-end: the Config screen renders the read-only agents (with the /// default marked) over the editable named viewers, drawn through the real /// screen draw path (DESIGN.md §5). diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 341be10..349476d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -421,7 +421,7 @@ Beyond the cockpit, the TUI cycles (Tab, or `alt-1`–`alt-4`, subject to the ga **The same row is where Voro answers back, and it grows to fit what it has to say.** A status message — a refusal, or the summary of something that just happened — takes the key line's row until the next keystroke, and it is *wrapped* across as many rows as it needs rather than truncated at the pane width. The reason is a property of the messages themselves: Voro's refusals are written to end on the way out, naming the key to press instead (§8's `g` on a checkout that is not GitHub points at `o`, and does it in the caller's own idiom), so a line cut at the right margin loses precisely the half worth reading, and loses it at whatever width the operator's terminal happens to be. The region is therefore sized from the wrapped message before the screen's panes divide the rows, and the panes above give up the space — an error the operator is being asked to act on outranks a row of the list they were browsing. It stops growing at half the screen, since a message that cannot be said in half a terminal is not going to be fixed by burying the lists under it, and it is one row again — for the key line, and for the short messages that fit — the moment there is nothing long to say. -**The right end of that row says which store is open, and only when it is not the operator's own.** Nothing else on screen answers "which database am I looking at?" — the one place it was ever said is the startup warning a `target/` build prints when it declines an inherited `VORO_DB` (§5), and the alternate screen paints over it before anyone reads it. So the footer carries the store's path, right-aligned against the key line the way the header right-aligns its counts, dim, on every screen rather than on the cockpit alone, since the question is not cockpit-specific. What decides whether it appears is the comparison against the *production* path (§5) rather than against whichever store the running binary defaults to, and that is the whole of the point: the default for a `target/` build **is** `dev.db`, so an indicator keyed on it would fall silent in exactly the case that raises the question. An installed `voro` on the operator's store therefore shows nothing at all, and the key line has the row entire, exactly as before; a dev build says `dev.db`, and a run under `--db` or an honoured `VORO_DB` says where it landed. It is the rule dispatch's `--db` flag and `voro seed`'s refusal already follow, for the same reason. The path is shortened to fit — `~` for the home directory, and past about a third of the row leading directories give way to a `…`, since the filename and its parent are the half that names the store — and the row never grows a line for it: an indicator that costs a row whenever it is absent would have to earn its place, and this one earns its place by costing nothing. When a status message is up it owns the row alone and the indicator is suppressed rather than right-aligned against wrapped text; the message is gone on the next keystroke and the store is not going anywhere. +**The right end of that row says which store is open, and only when it is not the operator's own.** Nothing else on screen answers "which database am I looking at?" — the one place it was ever said is the startup warning a `target/` build prints when it declines an inherited `VORO_DB` (§5), and the alternate screen paints over it before anyone reads it. So the footer carries the store's path, right-aligned against the key line the way the header right-aligns its counts, dim, on every screen rather than on the cockpit alone, since the question is not cockpit-specific. What decides whether it appears is the comparison against the *production* path (§5) rather than against whichever store the running binary defaults to, and that is the whole of the point: the default for a `target/` build **is** `dev.db`, so an indicator keyed on it would fall silent in exactly the case that raises the question. An installed `voro` on the operator's store therefore shows nothing at all, and the key line has the row entire, exactly as before; a dev build says `dev.db`, and a run under `--db` or an honoured `VORO_DB` says where it landed. It is the rule dispatch's `--db` flag and `voro seed`'s refusal already follow, for the same reason. What the row is sized against is the key line's slot budget, not the path: the line is measured first and the indicator takes only the columns left over, because the budget is fixed and documented and a store path's length is not, so the occupant that cannot be bounded is the one that yields. It shortens into whatever it is given — `~` for the home directory, then leading directories surrendered whole for a `…`, since the filename and its parent are the half that names the store — and the ladder ends at the bare filename, `dev.db` saying "not your store" in six columns. Below that the indicator disappears rather than push a single slot off the line, and it never cuts a name mid-word: a fragment identifies no store, and those columns belong to the keys. That last rung is a real, deliberate loss — a filename longer than the leftover leaves the row silent about a store that is not the operator's — and it is preferable to spending the line's recovery keys on half a name. The row never grows a line for any of this either: an indicator that costs a row whenever it is absent would have to earn its place, and this one earns its place by costing nothing. When a status message is up it owns the row alone and the indicator is suppressed rather than right-aligned against wrapped text; the message is gone on the next keystroke and the store is not going anywhere. **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.