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..ce8def8 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,7 +1882,80 @@ 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. 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(keys_line, 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 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, budget: u16) -> Option { + if db_path == Store::production_db_path() { + return None; + } + let home = std::env::var_os("HOME").map(PathBuf::from); + shorten_store_path(db_path, home.as_deref(), budget) +} + +/// 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()) + { + Some(rest) => format!("~/{}", rest.display()), + None => path.display().to_string(), + }; + 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 Some(candidate); + } + } + let name = parts.last().copied().unwrap_or_default(); + (!name.is_empty() && name.chars().count() <= budget).then(|| name.to_string()) } /// Whether the selection is a brief refine can still rewrite — a proposal or a @@ -2616,6 +2694,199 @@ 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 + ) + .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).as_deref(), + Some("/srv/voro/voro.db") + ); + } + + /// 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 the_store_shortens_into_the_columns_the_key_line_left() { + 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, 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") + ); + } + + /// 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, home, 7).as_deref(), + Some("voro.db"), + "the filename alone fits and is worth saying" + ); + // One column more and the `…` marking what was dropped fits too. + assert_eq!( + 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 + ); + } + + /// 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; + // 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); + }) + .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}" + ); + 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()); + 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}"); + } + + /// 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 1ee47c7..349476d 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. 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. 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.