From cc3d99d541128d0076c812bac1e1642fa8fdc4cf Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Wed, 12 Aug 2026 22:06:11 +0100 Subject: [PATCH] Collapse the review action to a per-project viewer name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting the review keys took the medium decision away from projects.review_action and left it naming one thing: which [viewers.] table a project's local diffs open in. The type still carried the old shape, where ReviewAction::Auto, ::Pr and ::Viewer(None) were three spellings of "name no viewer, use the default" — a value that has to be read twice before it can be trusted. The column is now projects.viewer, holding that name or NULL, and the enum is gone: Project::viewer is an Option. Migration 0017 converts in place — viewer: keeps its name, auto/pr/viewer become NULL. The CLI verb is `voro project viewer [NAME]`, naming no viewer to fall back to the default; the projects screen's `v` picker offers the default viewer and each named one instead of two entries that did nothing distinguishable. Verified against a copy of a real v16 database: all four legacy spellings migrate as intended and the TUI picker, its status line, the projects-row marker, and the viewer-delete guard read the new column. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbFevF3VMe27CLTXowayzG --- CHANGELOG.md | 11 ++ README.md | 2 +- .../migrations/0018_project_viewer.sql | 15 ++ crates/voro-core/src/agent.rs | 23 ++- crates/voro-core/src/config_edit.rs | 14 +- crates/voro-core/src/lib.rs | 2 +- crates/voro-core/src/model.rs | 117 +-------------- crates/voro-core/src/store.rs | 100 ++++++++---- crates/voro/src/app.rs | 142 +++++++++--------- crates/voro/src/cli.rs | 117 +++++++++------ crates/voro/src/dispatch.rs | 22 +-- crates/voro/src/ui.rs | 30 ++-- docs/DESIGN.md | 18 +-- 13 files changed, 295 insertions(+), 318 deletions(-) create mode 100644 crates/voro-core/migrations/0018_project_viewer.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index b0bbf1c..b1caaf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 but `voro show`'s event log — and no PR or configured viewer to fall back on, on the fresh install where that matters most. A rework's block is unchanged but for its heading, and shows nothing while the rework is still in flight. +- The per-project review action is now what it does: a viewer name. Since the + review keys split — `pr` always GitHub, `open` always a local viewer — the + setting decided only which `[viewers.]` table a project's local diffs + open in, so `projects.review_action` becomes `projects.viewer` and holds that + name or nothing. Existing databases convert in place: `viewer:` keeps + its name, and `auto`, `pr`, and a bare `viewer` — three spellings of "name no + viewer" — all become the default viewer. `voro project action

+ ` is now `voro project viewer

[NAME]`, naming no + viewer to fall back to the default, and the projects screen's `v` picker + offers the default and each named viewer instead of two entries that did + nothing distinguishable. - The cockpit key line advertises `d/D dispatch` only on a `ready` or `stalled` row, where dispatch can actually act, rather than on any selection — which also makes room for the new `a/A message` slot within the line's ten. diff --git a/README.md b/README.md index 4441583..74045fe 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ lands in `review` where `voro open` or `voro pr` puts the diff in front of you. To extend or override the built-in agents and viewers, layer a `~/.config/voro/voro.toml` on top (`voro agent init` writes a skeleton). The -dispatch semantics, the review action, and the `voro.toml` format are covered in +dispatch semantics, the per-project viewer, and the `voro.toml` format are covered in [`docs/DESIGN.md`](docs/DESIGN.md) §8; the `CLAUDE.md`/`AGENTS.md` return-path snippet and the Claude Code hooks configuration are in [`docs/agent-integration.md`](docs/agent-integration.md). diff --git a/crates/voro-core/migrations/0018_project_viewer.sql b/crates/voro-core/migrations/0018_project_viewer.sql new file mode 100644 index 0000000..e40dfff --- /dev/null +++ b/crates/voro-core/migrations/0018_project_viewer.sql @@ -0,0 +1,15 @@ +-- 0018: collapse the review action to what it names — a viewer. +-- +-- The review keys are static (§8): `pr` is always GitHub, `open` always a local +-- viewer. All the setting still decides is which [viewers.] table a +-- project's local diffs open in, so the column is that name: 'viewer:' +-- keeps , while 'auto', 'pr', and bare 'viewer' all meant "name no +-- viewer, use the default", which is NULL. + +UPDATE projects SET review_action = NULL +WHERE review_action IN ('auto', 'pr', 'viewer'); + +UPDATE projects SET review_action = substr(review_action, length('viewer:') + 1) +WHERE review_action LIKE 'viewer:%'; + +ALTER TABLE projects RENAME COLUMN review_action TO viewer; diff --git a/crates/voro-core/src/agent.rs b/crates/voro-core/src/agent.rs index 2431875..99d2810 100644 --- a/crates/voro-core/src/agent.rs +++ b/crates/voro-core/src/agent.rs @@ -188,14 +188,13 @@ const STARTER_HEADER: &str = r#"# Voro configuration (~/.config/voro/voro.toml). # * set `default_agent` — used for tasks with no --agent override. When unset, # Voro picks the first built-in found on PATH (claude, then codex). # * set up viewers — [viewers.] tables define how a task's diff is -# shown locally when `voro pr`/`voro open` resolve to the viewer medium -# (DESIGN.md §8). A viewer cmd may carry `{path}` (the task's worktree, or -# the project checkout when it has none), `{branch}` (the task's branch, or -# empty), and `{base}` (the checkout's default branch); `{base}...{branch}` -# spells a diff range. `default_viewer` names the one used when a project -# does not pick a viewer itself (`voro project action

viewer:`); a -# single anonymous [viewer] table is the older, still-valid spelling of -# the default. +# shown locally by `voro open` (DESIGN.md §8). A viewer cmd may carry +# `{path}` (the task's worktree, or the project checkout when it has none), +# `{branch}` (the task's branch, or empty), and `{base}` (the checkout's +# default branch); `{base}...{branch}` spells a diff range. +# `default_viewer` names the one used when a project does not pick a viewer +# itself (`voro project viewer

`); a single anonymous [viewer] +# table is the older, still-valid spelling of the default. # * price the queue — `max_running` caps how many dispatches ride at once # (default 5; at the cap the queue offers no more), and a [costs] table # divides each row's score by what its action asks of you, so a cheap @@ -783,8 +782,8 @@ pub struct AgentsConfig { /// The anonymous `[viewer]` table — the pre-names single viewer, still /// honoured as a default when no `default_viewer` is set. viewer: Option, - /// The named `[viewers.]` tables a project's review action can - /// pick from (DESIGN.md §8/§11a). + /// The named `[viewers.]` tables a project can pick from + /// (DESIGN.md §8/§11a). viewers: BTreeMap, /// The user-set `default_viewer`, naming a `[viewers.*]` entry. default_viewer: Option, @@ -991,8 +990,8 @@ impl AgentsConfig { }) } - /// The names of the `[viewers.*]` tables, sorted, for the TUI's - /// review-action picker and `viewer list`. + /// The names of the `[viewers.*]` tables, sorted, for the TUI's viewer + /// picker and `viewer list`. pub fn viewer_names(&self) -> Vec { self.viewers.keys().cloned().collect() } diff --git a/crates/voro-core/src/config_edit.rs b/crates/voro-core/src/config_edit.rs index 09a4c49..dcbf19e 100644 --- a/crates/voro-core/src/config_edit.rs +++ b/crates/voro-core/src/config_edit.rs @@ -15,7 +15,7 @@ use toml_edit::{DocumentMut, Item, Table, value}; use crate::agent::VIEWER_PATH_PLACEHOLDER; use crate::error::{Error, Result}; -use crate::model::{Project, ReviewAction}; +use crate::model::Project; /// Add a `[viewers.]` table with the given command, refusing an empty /// name/command or a name that collides with an existing viewer. Existing @@ -103,14 +103,13 @@ pub fn missing_path_placeholder(cmd: &str) -> bool { !cmd.contains(VIEWER_PATH_PLACEHOLDER) } -/// The projects whose review action names this viewer explicitly -/// (`viewer:`), so deleting it can be refused with them named (DESIGN.md -/// §5). A project on `viewer` (the unnamed default) is not counted — it follows +/// The projects that name this viewer, so deleting it can be refused with them +/// named (DESIGN.md §5). A project naming no viewer is not counted — it follows /// whatever the default resolves to rather than pinning this name. pub fn projects_referencing_viewer<'a>(projects: &'a [Project], name: &str) -> Vec<&'a Project> { projects .iter() - .filter(|p| matches!(&p.review_action, ReviewAction::Viewer(Some(n)) if n == name)) + .filter(|p| p.viewer.as_deref() == Some(name)) .collect() } @@ -118,9 +117,8 @@ fn validate_viewer(name: &str, cmd: &str) -> Result<()> { if name.is_empty() { return Err(invalid("viewer name is required".into())); } - // A name with whitespace or a colon cannot be referenced as `viewer:` - // by a project's review action, so refuse it rather than create an - // unreachable viewer. + // A name is typed as one word — on the command line and as the bare TOML + // key of its `[viewers.]` table — so refuse one that cannot be. if name.chars().any(|c| c.is_whitespace() || c == ':') { return Err(invalid(format!( "viewer name '{name}' cannot contain spaces or ':'" diff --git a/crates/voro-core/src/lib.rs b/crates/voro-core/src/lib.rs index 8603236..a51b561 100644 --- a/crates/voro-core/src/lib.rs +++ b/crates/voro-core/src/lib.rs @@ -27,7 +27,7 @@ pub use error::{Error, Result}; pub use import::{GithubIssue, already_imported, issue_new_task, issue_task_body}; pub use model::{ Dep, DepKind, DepRef, Doc, Event, NextAction, Priority, Project, RefineOutcome, Repo, - ReviewAction, RunningRow, Session, SessionOutcome, Task, TaskState, location_is_url, + RunningRow, Session, SessionOutcome, Task, TaskState, location_is_url, }; pub use pr::{Mergeability, PrPlan, PrRef, format_review_feedback, parse_mergeable, plan_pr}; pub use review::{ diff --git a/crates/voro-core/src/model.rs b/crates/voro-core/src/model.rs index 4657797..ae3fcdb 100644 --- a/crates/voro-core/src/model.rs +++ b/crates/voro-core/src/model.rs @@ -308,92 +308,17 @@ impl fmt::Display for RefineOutcome { } } -/// Which viewer a project's local diffs open in (DESIGN.md §8/§11a). The two -/// review keys are static — `g`/`pr` are always the GitHub PR flow, `o`/`open` -/// always a local viewer — so this no longer chooses between media; it names -/// the `voro.toml` viewer `o`/`open` resolve for this project. `Auto` and `Pr` -/// survive as stored values that name no viewer, leaving the default one. -/// Stored on the project (`projects.review_action`). -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum ReviewAction { - /// No viewer named — the default viewer. Stored as NULL, the unconfigured - /// default. - #[default] - Auto, - /// No viewer named either, kept so a project pinned to the GitHub flow - /// before the keys split still reads and writes. - Pr, - /// A local viewer from `voro.toml`: the named `[viewers.]` when one - /// is given, otherwise the default viewer. - Viewer(Option), -} - -impl ReviewAction { - /// Parse the stored/CLI form: `auto`, `pr`, `viewer`, or `viewer:`. - pub fn parse(s: &str) -> Result { - match s { - "auto" => Ok(ReviewAction::Auto), - "pr" => Ok(ReviewAction::Pr), - "viewer" => Ok(ReviewAction::Viewer(None)), - other => match other.strip_prefix("viewer:") { - Some(name) if !name.trim().is_empty() => { - Ok(ReviewAction::Viewer(Some(name.trim().to_string()))) - } - _ => Err(Error::Invalid(format!( - "unknown review action '{s}' — expected auto, pr, viewer, or viewer:" - ))), - }, - } - } - - /// The `voro.toml` viewer this project's local diffs open in, or `None` for - /// the default viewer. - pub fn viewer(&self) -> Option<&str> { - match self { - ReviewAction::Viewer(Some(name)) => Some(name), - _ => None, - } - } -} - -impl fmt::Display for ReviewAction { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ReviewAction::Auto => f.pad("auto"), - ReviewAction::Pr => f.pad("pr"), - ReviewAction::Viewer(None) => f.pad("viewer"), - ReviewAction::Viewer(Some(name)) => f.pad(&format!("viewer:{name}")), - } - } -} - -impl FromSql for ReviewAction { - fn column_result(value: ValueRef<'_>) -> FromSqlResult { - match value { - ValueRef::Null => Ok(ReviewAction::Auto), - _ => ReviewAction::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e))), - } - } -} - -impl ToSql for ReviewAction { - /// `Auto` writes NULL — absence of configuration — so the column stays - /// empty until the operator pins a medium. - fn to_sql(&self) -> rusqlite::Result> { - match self { - ReviewAction::Auto => Ok(rusqlite::types::Null.into()), - other => Ok(other.to_string().into()), - } - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct Project { pub id: i64, pub name: String, pub weight: i64, - /// How `pr` shows this project's review diffs (DESIGN.md §8/§11a). - pub review_action: ReviewAction, + /// The `voro.toml` viewer this project's local diffs open in (DESIGN.md + /// §8/§11a): a `[viewers.]` name, or `None` for the default viewer. + /// The review keys are static — `g`/`pr` are always the GitHub PR flow, + /// `o`/`open` always a local viewer — so this picks no medium, only the + /// viewer `o`/`open` resolve for this project. + pub viewer: Option, /// Retired (DESIGN.md §5): the project and all its tasks leave the cockpit /// — queue, stats, running strip — until unarchived. Tasks freeze in /// whatever state they hold; only the projects screen still shows the @@ -786,34 +711,4 @@ mod tests { assert_eq!(format!("{:10}", NextAction::ReviewPr), "review PR "); assert_eq!(format!("{:10}", NextAction::Redispatch), "redispatch"); } - - #[test] - fn review_action_parses_and_displays_every_form() { - for (text, action) in [ - ("auto", ReviewAction::Auto), - ("pr", ReviewAction::Pr), - ("viewer", ReviewAction::Viewer(None)), - ("viewer:zed", ReviewAction::Viewer(Some("zed".into()))), - ] { - assert_eq!(ReviewAction::parse(text).unwrap(), action, "{text}"); - assert_eq!(action.to_string(), text); - } - assert!(ReviewAction::parse("github").is_err()); - assert!(ReviewAction::parse("viewer:").is_err()); - assert!(ReviewAction::parse("viewer: ").is_err()); - } - - /// The narrowed role (DESIGN.md §8): the action names the viewer `o`/`open` - /// resolve, and only the `viewer:` form names one — the two legacy - /// forms leave the default viewer rather than choosing a medium. - #[test] - fn review_action_names_the_projects_viewer() { - assert_eq!( - ReviewAction::Viewer(Some("zed".into())).viewer(), - Some("zed") - ); - assert_eq!(ReviewAction::Viewer(None).viewer(), None); - assert_eq!(ReviewAction::Auto.viewer(), None); - assert_eq!(ReviewAction::Pr.viewer(), None); - } } diff --git a/crates/voro-core/src/store.rs b/crates/voro-core/src/store.rs index 7a22b21..c9ccf67 100644 --- a/crates/voro-core/src/store.rs +++ b/crates/voro-core/src/store.rs @@ -5,8 +5,8 @@ use rusqlite::{Connection, OptionalExtension, params}; use crate::error::{Error, Result}; use crate::model::{ - Dep, DepKind, DepRef, Doc, Event, Priority, Project, RefineOutcome, Repo, ReviewAction, - RunningRow, Session, SessionOutcome, Task, TaskState, location_is_url, + Dep, DepKind, DepRef, Doc, Event, Priority, Project, RefineOutcome, Repo, RunningRow, Session, + SessionOutcome, Task, TaskState, location_is_url, }; const MIGRATIONS: &[&str] = &[ @@ -27,6 +27,7 @@ const MIGRATIONS: &[&str] = &[ include_str!("../migrations/0015_dep_kind_in_key.sql"), include_str!("../migrations/0016_add_refining_state.sql"), include_str!("../migrations/0017_schema_migrations.sql"), + include_str!("../migrations/0018_project_viewer.sql"), ]; /// Whether a path lies inside a Cargo build directory — a `target` component @@ -453,12 +454,21 @@ impl Store { Ok(()) } - /// Set how `pr` shows this project's review diffs (DESIGN.md §8/§11a). - /// `Auto` stores NULL — the medium goes back to being resolved at use. - pub fn set_review_action(&mut self, project_id: i64, action: &ReviewAction) -> Result { + /// Name the `voro.toml` viewer this project's local diffs open in + /// (DESIGN.md §8/§11a). `None` stores NULL — no viewer named, so `open` + /// falls back to the config's default viewer. + pub fn set_viewer(&mut self, project_id: i64, viewer: Option<&str>) -> Result { + let viewer = match viewer.map(str::trim) { + Some("") => { + return Err(Error::Invalid( + "viewer name is required — name no viewer to use the default one".into(), + )); + } + named => named, + }; let changed = self.conn.execute( - "UPDATE projects SET review_action = ?1 WHERE id = ?2", - params![action, project_id], + "UPDATE projects SET viewer = ?1 WHERE id = ?2", + params![viewer, project_id], )?; if changed == 0 { return Err(Error::ProjectNotFound(project_id)); @@ -1787,14 +1797,14 @@ fn session_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { }) } -pub(crate) const PROJECT_COLUMNS: &str = "id, name, weight, review_action, archived"; +pub(crate) const PROJECT_COLUMNS: &str = "id, name, weight, viewer, archived"; fn project_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Project { id: row.get(0)?, name: row.get(1)?, weight: row.get(2)?, - review_action: row.get(3)?, + viewer: row.get(3)?, archived: row.get(4)?, }) } @@ -2168,37 +2178,75 @@ mod tests { } #[test] - fn review_action_defaults_to_auto_and_round_trips() { - use crate::model::ReviewAction; + fn project_viewer_defaults_to_none_and_round_trips() { let mut s = Store::open_in_memory().unwrap(); let p = s.create_project("proj", "/tmp/proj").unwrap(); - assert_eq!(p.review_action, ReviewAction::Auto); + assert_eq!(p.viewer, None); - let action = ReviewAction::Viewer(Some("zed".into())); - let updated = s.set_review_action(p.id, &action).unwrap(); - assert_eq!(updated.review_action, action); - assert_eq!(s.project(p.id).unwrap().review_action, action); - assert_eq!(s.projects().unwrap()[0].review_action, action); + let updated = s.set_viewer(p.id, Some("zed")).unwrap(); + assert_eq!(updated.viewer.as_deref(), Some("zed")); + assert_eq!(s.project(p.id).unwrap().viewer.as_deref(), Some("zed")); + assert_eq!(s.projects().unwrap()[0].viewer.as_deref(), Some("zed")); - // Auto writes NULL, so the column reads back empty - s.set_review_action(p.id, &ReviewAction::Auto).unwrap(); - assert_eq!(s.project(p.id).unwrap().review_action, ReviewAction::Auto); + // Naming no viewer writes NULL, so the column reads back empty + s.set_viewer(p.id, None).unwrap(); + assert_eq!(s.project(p.id).unwrap().viewer, None); let raw: Option = s .conn - .query_row( - "SELECT review_action FROM projects WHERE id = ?1", - [p.id], - |r| r.get(0), - ) + .query_row("SELECT viewer FROM projects WHERE id = ?1", [p.id], |r| { + r.get(0) + }) .unwrap(); assert_eq!(raw, None); + // A blank name is a typo, not a way to clear the viewer + assert!(matches!( + s.set_viewer(p.id, Some(" ")), + Err(Error::Invalid(_)) + )); assert!(matches!( - s.set_review_action(999, &ReviewAction::Pr), + s.set_viewer(999, Some("zed")), Err(Error::ProjectNotFound(999)) )); } + /// A database from before migration 0018 carries review actions in the + /// pre-split spellings (DESIGN.md §5/§8). Opening it must keep the viewer a + /// project named and read the three spellings that named none as none. + #[test] + fn migration_0018_reads_review_actions_as_viewer_names() { + let conn = Connection::open_in_memory().unwrap(); + for sql in &MIGRATIONS[..17] { + conn.execute_batch(sql).unwrap(); + } + conn.pragma_update(None, "user_version", 17).unwrap(); + conn.execute( + "INSERT INTO projects (name, review_action) VALUES + ('named', 'viewer:zed'), + ('bare-viewer', 'viewer'), + ('auto', 'auto'), + ('pinned-to-pr', 'pr'), + ('unset', NULL)", + [], + ) + .unwrap(); + + let mut store = Store::from_connection(conn).unwrap(); + let viewer_of = |store: &mut Store, name: &str| { + store + .projects() + .unwrap() + .into_iter() + .find(|p| p.name == name) + .unwrap() + .viewer + }; + assert_eq!(viewer_of(&mut store, "named").as_deref(), Some("zed")); + for named_none in ["bare-viewer", "auto", "pinned-to-pr", "unset"] { + assert_eq!(viewer_of(&mut store, named_none), None, "{named_none}"); + } + } + #[test] fn rename_project_rejects_unknown_id() { let mut s = Store::open_in_memory().unwrap(); diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index c337b06..c393909 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -3,8 +3,8 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::ui::Hit; use voro_core::{ Action, ActionRow, AgentsConfig, CompletionReport, DepKind, DepRef, DigestRow, Event, PrRef, - Priority, Project, Queue, QueueRow, RefineOutcome, ReviewAction, RunningRow, ScoreBreakdown, - StateCounts, Store, Task, TaskState, Triage, WipGate, scheduler, + Priority, Project, Queue, QueueRow, RefineOutcome, RunningRow, ScoreBreakdown, StateCounts, + Store, Task, TaskState, Triage, WipGate, scheduler, }; /// Lines `PgDn`/`PgUp` move the focus card in one press. A fixed step, since @@ -32,16 +32,23 @@ pub enum DefaultKind { Viewer, } -/// One option in the review-action picker (DESIGN.md §8/§11a). Beyond the real -/// [`ReviewAction`] choices, the trailing `NewViewer` entry opens the add-viewer -/// form and pins the project to the viewer it creates — first-time viewer setup -/// without a detour through the Config screen. +/// One option in the project's viewer picker (DESIGN.md §8/§11a). Beyond the +/// viewers themselves — `None` for the config default, then each named one — +/// the trailing `NewViewer` entry opens the add-viewer form and pins the +/// project to the viewer it creates, first-time viewer setup without a detour +/// through the Config screen. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReviewActionOption { - Action(ReviewAction), +pub enum ViewerOption { + Viewer(Option), NewViewer, } +/// How a project's viewer choice reads on screen and at the shell: the name it +/// pins, or the config default when it names none (DESIGN.md §8). +pub fn viewer_label(viewer: Option<&str>) -> &str { + viewer.unwrap_or("default viewer") +} + /// An agent row on the Config screen (DESIGN.md §5): the effective set with /// provenance and the default marked, read-only in this cut. #[derive(Debug, Clone)] @@ -190,16 +197,16 @@ pub enum Mode { sel: usize, back: Option, }, - /// Picking a project's review action on the projects screen (DESIGN.md - /// §8/§11a): auto, pr, the default viewer, each named viewer from - /// `voro.toml`, and a trailing "new viewer…" that opens the add-viewer form. - /// Loaded fresh so a just-added viewer shows up. - ReviewActionPicker { + /// Picking a project's viewer on the projects screen (DESIGN.md §8/§11a): + /// the default viewer, each named viewer from `voro.toml`, and a trailing + /// "new viewer…" that opens the add-viewer form. Loaded fresh so a + /// just-added viewer shows up. + ViewerPicker { project_id: i64, - options: Vec, - /// The project's action as stored, flagged in the list independently - /// of cursor position. - current: ReviewAction, + options: Vec, + /// The viewer the project names as stored, flagged in the list + /// independently of cursor position. + current: Option, sel: usize, }, /// The add/edit-viewer form on the Config screen (DESIGN.md §5): a name and @@ -237,7 +244,7 @@ impl Mode { | Mode::Transition { sel, .. } | Mode::AgentPicker { sel, .. } | Mode::DocPicker { sel, .. } - | Mode::ReviewActionPicker { sel, .. } + | Mode::ViewerPicker { sel, .. } | Mode::DefaultPicker { sel, .. } => Some(*sel), _ => None, } @@ -249,7 +256,7 @@ impl Mode { | Mode::Transition { sel, .. } | Mode::AgentPicker { sel, .. } | Mode::DocPicker { sel, .. } - | Mode::ReviewActionPicker { sel, .. } + | Mode::ViewerPicker { sel, .. } | Mode::DefaultPicker { sel, .. } => Some(sel), _ => None, } @@ -1161,12 +1168,12 @@ impl App { sel, back, } => self.key_doc_picker(key, task_id, docs, sel, back), - Mode::ReviewActionPicker { + Mode::ViewerPicker { project_id, options, current, sel, - } => self.key_review_action_picker(key, project_id, options, current, sel), + } => self.key_viewer_picker(key, project_id, options, current, sel), Mode::ViewerForm { name, cmd, @@ -2421,7 +2428,7 @@ impl App { /// The projects screen's local keys (DESIGN.md §9). `0`–`5` sets the /// selected project's weight; `r` opens the AddProject form pre-filled to /// rename/re-path, `a` opens it blank, `d` deletes behind the store's own - /// guard (only projects with no tasks), `v` picks the review action, `A` + /// guard (only projects with no tasks), `v` picks the viewer, `A` /// toggles archived (DESIGN.md §5). Movement and screen switching are /// handled by `key_normal`. fn key_projects(&mut self, key: KeyEvent) { @@ -2465,8 +2472,8 @@ impl App { } KeyCode::Char('v') => { if let Some(project) = self.projects.get(self.projects_sel) { - let (id, current) = (project.id, project.review_action.clone()); - self.open_review_action_picker(id, current); + let (id, current) = (project.id, project.viewer.clone()); + self.open_viewer_picker(id, current); } } KeyCode::Char('A') => { @@ -2486,11 +2493,11 @@ impl App { } } - /// Open the review-action picker for a project (DESIGN.md §8/§11a): auto, - /// pr, the default viewer, and each named viewer from `voro.toml`. The - /// config is loaded fresh so a just-added `[viewers.*]` table shows up; - /// the cursor starts on the project's current action. - fn open_review_action_picker(&mut self, project_id: i64, current: ReviewAction) { + /// Open the viewer picker for a project (DESIGN.md §8/§11a): the default + /// viewer, then each named viewer from `voro.toml`. The config is loaded + /// fresh so a just-added `[viewers.*]` table shows up; the cursor starts on + /// the viewer the project names. + fn open_viewer_picker(&mut self, project_id: i64, current: Option) { let config = match AgentsConfig::load(&self.dispatch_ctx.agents_path) { Ok(config) => config, Err(e) => { @@ -2498,25 +2505,21 @@ impl App { return; } }; - let mut options = vec![ - ReviewActionOption::Action(ReviewAction::Auto), - ReviewActionOption::Action(ReviewAction::Pr), - ReviewActionOption::Action(ReviewAction::Viewer(None)), - ]; + let mut options = vec![ViewerOption::Viewer(None)]; options.extend( config .viewer_names() .into_iter() - .map(|name| ReviewActionOption::Action(ReviewAction::Viewer(Some(name)))), + .map(|name| ViewerOption::Viewer(Some(name))), ); // The quick path (DESIGN.md §5): a trailing entry that opens the // add-viewer form and pins this project to the viewer it creates. - options.push(ReviewActionOption::NewViewer); + options.push(ViewerOption::NewViewer); let sel = options .iter() - .position(|o| matches!(o, ReviewActionOption::Action(a) if *a == current)) + .position(|o| matches!(o, ViewerOption::Viewer(v) if *v == current)) .unwrap_or(0); - self.mode = Mode::ReviewActionPicker { + self.mode = Mode::ViewerPicker { project_id, options, current, @@ -2524,15 +2527,15 @@ impl App { }; } - /// Drive the review-action picker: ⏎ stores the highlighted action via - /// `set_review_action` and refreshes so the projects row reflects it; + /// Drive the viewer picker: ⏎ stores the highlighted viewer via + /// `set_viewer` and refreshes so the projects row reflects it; /// esc cancels without touching anything. - fn key_review_action_picker( + fn key_viewer_picker( &mut self, key: KeyEvent, project_id: i64, - options: Vec, - current: ReviewAction, + options: Vec, + current: Option, mut sel: usize, ) { match key.code { @@ -2543,19 +2546,20 @@ impl App { KeyCode::Char('k') | KeyCode::Up => sel = sel.saturating_sub(1), KeyCode::Enter => { match options.get(sel) { - Some(ReviewActionOption::Action(action)) => { - let action = action.clone(); + Some(ViewerOption::Viewer(viewer)) => { + let viewer = viewer.clone(); let result = self .store - .set_review_action(project_id, &action) + .set_viewer(project_id, viewer.as_deref()) .and_then(|_| self.refresh()); if self.report(result).is_some() { - self.status = Some(format!("review action -> {action}")); + self.status = + Some(format!("viewer -> {}", viewer_label(viewer.as_deref()))); } } // Open the shared add-viewer form; on success it pins this // project to the new viewer (DESIGN.md §5). - Some(ReviewActionOption::NewViewer) => { + Some(ViewerOption::NewViewer) => { self.open_viewer_form(None, Some(project_id)); } None => {} @@ -2564,7 +2568,7 @@ impl App { } _ => {} } - self.mode = Mode::ReviewActionPicker { + self.mode = Mode::ViewerPicker { project_id, options, current, @@ -2591,7 +2595,7 @@ impl App { /// Open the add/edit-viewer form. `existing` pre-fills it for an edit (name /// locked); `review_project` threads through the quick path so a viewer - /// created from the review-action picker becomes that project's action. + /// created from the viewer picker becomes that project's viewer. fn open_viewer_form( &mut self, existing: Option<(String, String)>, @@ -2621,8 +2625,8 @@ impl App { } } - /// Delete the selected viewer, refusing when a project's review action still - /// names it (DESIGN.md §5) — the same refusal as `voro viewer remove`, with + /// Delete the selected viewer, refusing when a project still names it + /// (DESIGN.md §5) — the same refusal as `voro viewer remove`, with /// the offending projects named. Deleting the default clears `default_viewer`. fn delete_selected_viewer(&mut self) { let Some(viewer) = self.config_viewers.get(self.config_sel) else { @@ -2635,7 +2639,7 @@ impl App { if !referencing.is_empty() { let names: Vec<&str> = referencing.iter().map(|p| p.name.as_str()).collect(); self.status = Some(format!( - "'{name}' is the review action of {} — repoint it first (v on the projects screen)", + "'{name}' is the viewer of {} — repoint it first (v on the projects screen)", names.join(", ") )); return; @@ -2788,9 +2792,8 @@ impl App { msg.push_str(" (no {path} — runs in the checkout dir)"); } if let Some(project_id) = review_project { - let action = voro_core::ReviewAction::Viewer(Some(trimmed.clone())); - match self.store.set_review_action(project_id, &action) { - Ok(_) => msg.push_str(" — set as this project's review action"), + match self.store.set_viewer(project_id, Some(&trimmed)) { + Ok(_) => msg.push_str(" — set as this project's viewer"), Err(e) => msg = e.to_string(), } } @@ -4168,19 +4171,14 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - /// Deleting a viewer a project's review action still names is refused on the + /// Deleting a viewer a project still names is refused on the /// Config screen too, naming the project (DESIGN.md §5). #[test] fn config_screen_refuses_to_delete_a_referenced_viewer() { let toml = "[viewers.zed]\ncmd = \"zed {path}\"\n"; let (mut store, ctx, _project) = scratch_env("config-ref", Some(toml)); let project = store.create_project("demo2", "/tmp/demo2").unwrap(); - store - .set_review_action( - project.id, - &voro_core::ReviewAction::Viewer(Some("zed".into())), - ) - .unwrap(); + store.set_viewer(project.id, Some("zed")).unwrap(); let path = ctx.agents_path.clone(); let mut app = App::new(store, ctx).unwrap(); @@ -4204,11 +4202,11 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } - /// The quick path (DESIGN.md §5): the projects screen's review-action picker + /// The quick path (DESIGN.md §5): the projects screen's viewer picker /// grows a "new viewer…" entry that opens the add-viewer form and, on /// success, pins the project to the viewer it created. #[test] - fn review_action_picker_new_viewer_creates_and_pins_it() { + fn viewer_picker_new_viewer_creates_and_pins_it() { let (mut store, ctx, project_path) = scratch_env("config-quickpath", None); let project = store .create_project("demo", project_path.to_str().unwrap()) @@ -4216,13 +4214,13 @@ mod tests { let path = ctx.agents_path.clone(); let mut app = App::new(store, ctx).unwrap(); - // onto the projects screen, open the review-action picker + // onto the projects screen, open the viewer picker key(&mut app, KeyCode::Char('3')); assert_eq!(app.screen, Screen::Projects); key(&mut app, KeyCode::Char('v')); let n = match &app.mode { - Mode::ReviewActionPicker { options, .. } => options.len(), - _ => panic!("expected the review-action picker to open"), + Mode::ViewerPicker { options, .. } => options.len(), + _ => panic!("expected the viewer picker to open"), }; // the last option is "new viewer…"; move to it and select for _ in 0..n { @@ -4252,8 +4250,8 @@ mod tests { .contains(&"emacs".to_string()) ); assert_eq!( - app.store.project(project.id).unwrap().review_action, - voro_core::ReviewAction::Viewer(Some("emacs".into())) + app.store.project(project.id).unwrap().viewer.as_deref(), + Some("emacs") ); let _ = std::fs::remove_dir_all(path.parent().unwrap()); @@ -5606,9 +5604,7 @@ mod tests { let mut store = Store::open_in_memory().unwrap(); let project = store.create_project("demo", dir.to_str().unwrap()).unwrap(); - store - .set_review_action(project.id, &ReviewAction::Viewer(Some("zed".into()))) - .unwrap(); + store.set_viewer(project.id, Some("zed")).unwrap(); let task = store .create_task(NewTask { project_id: project.id, diff --git a/crates/voro/src/cli.rs b/crates/voro/src/cli.rs index 7a5b052..7531659 100644 --- a/crates/voro/src/cli.rs +++ b/crates/voro/src/cli.rs @@ -10,10 +10,10 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; use voro_core::{ Action, AgentsConfig, DepKind, Doc, Event, NewTask, NextAction, PrRef, Priority, Project, - QueueRow, RefineOutcome, Repo, ReviewAction, Store, Task, TaskEdit, TaskState, Triage, WipGate, - scheduler, + QueueRow, RefineOutcome, Repo, Store, Task, TaskEdit, TaskState, Triage, WipGate, scheduler, }; +use crate::app::viewer_label; use crate::dispatch::{self, DispatchCtx}; use crate::import; use crate::pr::ForgeMemo; @@ -38,13 +38,11 @@ projects project delete delete a project with no tasks — park it (weight 0) or archive it instead to retire one that has any - project action - set which viewer `open` shows the project's - local diffs in: viewer:NAME picks a - [viewers.NAME] entry from voro.toml, while - auto, pr, and bare viewer all leave the - default one (`pr` is always GitHub now, so - the medium is no longer a project setting) + project viewer [NAME] set which viewer `open` shows the project's + local diffs in: NAME picks a [viewers.NAME] + entry from voro.toml, and naming none leaves + the default viewer (`pr` is always GitHub, so + the medium is not a project setting) weight <0-5> set a project's weight (0 parks it) repos a project allocates attention; its repos @@ -190,12 +188,12 @@ dispatch viewer add define a [viewers.NAME] entry in voro.toml (comment-preserving); cmd may carry {path}, {branch}, {base} (e.g. 'zed {path}') - viewer remove delete a viewer; refused while a project's - review action still names it + viewer remove delete a viewer; refused while a project + still names it open open a review/running task's checkout in a voro.toml viewer to see its diff — the only local-diff spelling, and the one `project - action` names a viewer for; reports what to + viewer` names a viewer for; reports what to configure if none is set pr [--yes] show the task's diff on GitHub, always: jump to the tracked PR in a browser, or push the @@ -373,14 +371,32 @@ enum Verb { #[derive(Subcommand)] enum ProjectCmd { - Add { name: String, path: String }, + Add { + name: String, + path: String, + }, List, - Rename { project: String, name: String }, - Path { project: String, path: String }, - Archive { project: String }, - Unarchive { project: String }, - Delete { project: String }, - Action { project: String, action: String }, + Rename { + project: String, + name: String, + }, + Path { + project: String, + path: String, + }, + Archive { + project: String, + }, + Unarchive { + project: String, + }, + Delete { + project: String, + }, + Viewer { + project: String, + name: Option, + }, } /// The checkouts under a project (DESIGN.md §3/§5). A project always has at @@ -886,9 +902,9 @@ fn project_verb(store: &mut Store, cmd: ProjectCmd) -> Result { ProjectCmd::List => { let mut out = String::new(); for p in store.projects().map_err(|e| e.to_string())? { - let action = match &p.review_action { - ReviewAction::Auto => String::new(), - other => format!(" [{other}]"), + let viewer = match &p.viewer { + Some(name) => format!(" [viewer:{name}]"), + None => String::new(), }; let archived = if p.archived { " [archived]" } else { "" }; // The path column stays the default repo's, so a single-repo @@ -905,7 +921,7 @@ fn project_verb(store: &mut Store, cmd: ProjectCmd) -> Result { }; writeln!( out, - "{:3} w{} {} {}{extra}{action}{archived}", + "{:3} w{} {} {}{extra}{viewer}{archived}", p.id, p.weight, p.name, path ) .unwrap(); @@ -960,15 +976,16 @@ fn project_verb(store: &mut Store, cmd: ProjectCmd) -> Result { .map_err(|e| e.to_string())?; Ok(format!("project {} '{}' deleted", project.id, project.name)) } - ProjectCmd::Action { project, action } => { + ProjectCmd::Viewer { project, name } => { let project = resolve_project(store, &project)?; - let action = ReviewAction::parse(&action).map_err(|e| e.to_string())?; let p = store - .set_review_action(project.id, &action) + .set_viewer(project.id, name.as_deref()) .map_err(|e| e.to_string())?; Ok(format!( - "{} review action {} -> {}", - p.name, project.review_action, p.review_action + "{} viewer: {} -> {}", + p.name, + viewer_label(project.viewer.as_deref()), + viewer_label(p.viewer.as_deref()) )) } } @@ -1571,7 +1588,7 @@ fn doc_link_echo(docs: &[Doc]) -> String { } /// `pr [--yes]` (DESIGN.md §8/§11c): the GitHub half of "show me this -/// task's diff", whatever the project's review action says. With a tracked PR, +/// task's diff", whatever viewer the project names. With a tracked PR, /// open it in a browser. Without one, create the PR from the review task's /// done-time state — asserting PR-readiness, confirming unless `--yes`. A /// checkout that cannot take a PR errors pointing at `voro open`, which is the @@ -2185,7 +2202,7 @@ fn explain_verb(store: &mut Store, id: i64, ctx: &DispatchCtx) -> Result Result { let path = &ctx.agents_path; match cmd { @@ -2206,8 +2223,8 @@ fn viewer_verb(store: &mut Store, cmd: ViewerCmd, ctx: &DispatchCtx) -> Result = referencing.iter().map(|p| p.name.as_str()).collect(); return Err(format!( - "viewer '{name}' is the review action of {} — repoint {} with `voro project \ - action ` before removing it", + "viewer '{name}' is the viewer of {} — repoint {} with `voro project \ + viewer [NAME]` before removing it", names.join(", "), if referencing.len() == 1 { "it" } else { "them" } )); @@ -2592,14 +2609,14 @@ mod tests { let out = call(&mut s, &["viewer", "add", "difftool", "git difftool -d"]).unwrap(); assert!(out.contains("{path}"), "{out}"); - // a project pinned to viewer:zed blocks its removal, naming the project + // a project naming zed blocks its removal, naming the project call(&mut s, &["project", "add", "demo", "/tmp/demo"]).unwrap(); - call(&mut s, &["project", "action", "demo", "viewer:zed"]).unwrap(); + call(&mut s, &["project", "viewer", "demo", "zed"]).unwrap(); let e = call(&mut s, &["viewer", "remove", "zed"]).unwrap_err(); - assert!(e.contains("demo") && e.contains("review action"), "{e}"); + assert!(e.contains("demo") && e.contains("is the viewer of"), "{e}"); // repoint the project, then removal succeeds and list loses it - call(&mut s, &["project", "action", "demo", "auto"]).unwrap(); + call(&mut s, &["project", "viewer", "demo"]).unwrap(); let out = call(&mut s, &["viewer", "remove", "zed"]).unwrap(); assert!(out.contains("removed"), "{out}"); let listed = call(&mut s, &["viewer", "list"]).unwrap(); @@ -4159,7 +4176,7 @@ mod tests { assert!(out.contains("--summary-file"), "{out}"); } - // --- the review action's viewer, and the static `pr` (DESIGN.md §8/§11a) --- + // --- the project's viewer, and the static `pr` (DESIGN.md §8/§11a) --- /// A DispatchCtx whose voro.toml is the given text, isolated under a temp /// root — the CLI-test face of the dispatch fixtures, for verbs that read @@ -4191,25 +4208,27 @@ mod tests { } #[test] - fn project_action_sets_shows_and_rejects() { + fn project_viewer_sets_clears_and_shows() { let mut s = store(); ok(&mut s, &["project", "add", "demo", "/tmp"]); - let out = ok(&mut s, &["project", "action", "demo", "viewer:zed"]); - assert!(out.contains("auto -> viewer:zed"), "{out}"); + let out = ok(&mut s, &["project", "viewer", "demo", "zed"]); + assert!(out.contains("default viewer -> zed"), "{out}"); assert!( ok(&mut s, &["project", "list"]).contains("[viewer:zed]"), - "list must show a pinned action" + "list must show the viewer a project names" ); - ok(&mut s, &["project", "action", "demo", "auto"]); + // naming no viewer clears it back to the default, which earns no marker + let out = ok(&mut s, &["project", "viewer", "demo"]); + assert!(out.contains("zed -> default viewer"), "{out}"); assert!( !ok(&mut s, &["project", "list"]).contains("[viewer"), - "auto is the default and earns no marker" + "the default viewer earns no marker" ); - let e = err(&mut s, &["project", "action", "demo", "github"]); - assert!(e.contains("auto, pr, viewer"), "{e}"); - assert!(ok(&mut s, &["help"]).contains("project action"), "help"); + let e = err(&mut s, &["project", "viewer", "nope", "zed"]); + assert!(e.contains("nope"), "{e}"); + assert!(ok(&mut s, &["help"]).contains("project viewer"), "help"); } #[test] @@ -4237,7 +4256,7 @@ mod tests { /// `pr` is statically the GitHub medium (DESIGN.md §8): on a checkout that /// cannot take a pull request it errors pointing at `voro open`, and the - /// project's review action — a viewer, here — does not redirect it. The + /// viewer the project names does not redirect it. The /// viewer would leave a marker behind, so its absence is the assertion. #[test] fn pr_on_a_non_github_checkout_errors_pointing_at_open() { @@ -4250,7 +4269,7 @@ mod tests { &mut s, &["project", "add", "demo", project_dir.to_str().unwrap()], ); - ok(&mut s, &["project", "action", "demo", "viewer:marker"]); + ok(&mut s, &["project", "viewer", "demo", "marker"]); // PR-ready in every way but the checkout, so the refusal can only be // about the medium. let id = review_task(&mut s, Some("feat/thing"), Some("did it")); @@ -4317,7 +4336,7 @@ mod tests { fn done_warning_promises_no_pr_failure_on_a_viewer_project() { let mut s = store(); ok(&mut s, &["project", "add", "demo", "/tmp"]); - ok(&mut s, &["project", "action", "demo", "viewer:zed"]); + ok(&mut s, &["project", "viewer", "demo", "zed"]); ok(&mut s, &["add", "demo", "T", "--state", "ready"]); ok(&mut s, &["start", "1"]); let out = ok(&mut s, &["done", "1", "--branch", "feat/x"]); diff --git a/crates/voro/src/dispatch.rs b/crates/voro/src/dispatch.rs index 1a18ada..1f6084e 100644 --- a/crates/voro/src/dispatch.rs +++ b/crates/voro/src/dispatch.rs @@ -1142,12 +1142,12 @@ pub fn dispatch( } /// Open a `review` (or `running`) task's diff in a viewer (DESIGN.md §11a): the -/// viewer medium of the per-project review action (§8). A dispatched agent works +/// only local-diff spelling (§8). A dispatched agent works /// in a throwaway worktree on the task's branch, so the diff lives there, not in /// the primary checkout — the viewer is run in that worktree when the task has a /// live one, falling back to the task's resolved repo when it has no branch or no worktree /// (§8). `viewer_override` names a `[viewers.]` entry; `None` falls back to -/// the project's review action or the config default. The viewer template gets +/// the viewer the project names, or the config default. The viewer template gets /// `{path}` (the resolved dir), `{branch}` (the task's branch, empty when none), /// and `{base}` (the checkout's default branch) so it can express a diff range. /// Opening never touches task state, so there is no clean-tree guard (the diff @@ -1170,10 +1170,9 @@ pub fn open( // second repo has its branch and worktree there (DESIGN.md §8). let repo = store.repo_for_task(&task).map_err(|e| e.to_string())?; - // The project's review action names the viewer its local diffs open in - // (DESIGN.md §8), which is all that setting still decides. - let project_viewer = project.review_action.viewer().map(str::to_string); - let viewer_name = viewer_override.map(str::to_string).or(project_viewer); + let viewer_name = viewer_override + .map(str::to_string) + .or_else(|| project.viewer.clone()); let config = AgentsConfig::load(&ctx.agents_path).map_err(|e| e.to_string())?; let viewer = config @@ -2614,9 +2613,9 @@ mod tests { /// The two named-viewer selection paths (DESIGN.md §8/§11a): an explicit /// override picks its `[viewers.]` entry over the default, and with - /// no override the project's `viewer:` review action picks one. + /// no override the viewer the project names picks one. #[test] - fn open_picks_the_named_viewer_from_override_or_project_action() { + fn open_picks_the_named_viewer_from_override_or_project() { let (mut store, ctx, project) = fixture("cat {prompt_file}"); std::fs::write( &ctx.agents_path, @@ -2639,12 +2638,7 @@ mod tests { std::fs::remove_file(&marker).unwrap(); let project_id = store.task(id).unwrap().project_id; - store - .set_review_action( - project_id, - &voro_core::ReviewAction::Viewer(Some("special".into())), - ) - .unwrap(); + store.set_viewer(project_id, Some("special")).unwrap(); open(&mut store, &ctx, id, None).unwrap(); for _ in 0..50 { if marker.exists() { diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index 6a3fc6b..c675dc5 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -9,7 +9,7 @@ use voro_core::{ ScoreBreakdown, Session, SessionOutcome, StateCounts, TaskState, }; -use crate::app::{App, CockpitRow, Mode, ReviewActionOption, Screen, TaskRow}; +use crate::app::{App, CockpitRow, Mode, Screen, TaskRow, ViewerOption, viewer_label}; const SELECTED: Style = Style::new().add_modifier(Modifier::REVERSED); @@ -369,7 +369,7 @@ fn draw_mode(frame: &mut Frame, app: &App, hits: &mut HitMap) { frame.render_stateful_widget(list, area, &mut state); hits.push_list(area, state.offset(), count, Hit::PickerOption); } - Mode::ReviewActionPicker { + Mode::ViewerPicker { options, current, sel, @@ -378,11 +378,13 @@ fn draw_mode(frame: &mut Frame, app: &App, hits: &mut HitMap) { let items: Vec = options .iter() .map(|o| match o { - ReviewActionOption::Action(a) if a == current => { - ListItem::new(format!("{a} (current)")) + ViewerOption::Viewer(v) if v == current => { + ListItem::new(format!("{} (current)", viewer_label(v.as_deref()))) } - ReviewActionOption::Action(a) => ListItem::new(a.to_string()), - ReviewActionOption::NewViewer => ListItem::new(Line::from(Span::styled( + ViewerOption::Viewer(v) => { + ListItem::new(viewer_label(v.as_deref()).to_string()) + } + ViewerOption::NewViewer => ListItem::new(Line::from(Span::styled( "new viewer…", Style::new().fg(Color::Blue), ))), @@ -396,7 +398,7 @@ fn draw_mode(frame: &mut Frame, app: &App, hits: &mut HitMap) { .block( Block::default() .borders(Borders::ALL) - .title("Review action — ⏎ set, esc cancel"), + .title("Viewer — ⏎ set, esc cancel"), ) .highlight_style(SELECTED); frame.render_stateful_widget(list, area, &mut state); @@ -1547,7 +1549,7 @@ fn blocker_spans(row: &TaskRow) -> Vec> { } /// The projects screen (DESIGN.md §9): one row per project — weight, name, -/// path, open task count, and the review action when one is pinned (§8). The +/// path, open task count, and the viewer when the project names one (§8). The /// open count is the project's non-terminal tasks, from the loaded task list. /// An archived project stays on this screen, dim and tagged, so it can be /// found and unarchived (§5). @@ -1572,9 +1574,9 @@ fn draw_projects(frame: &mut Frame, app: &App, hits: &mut HitMap) { } else { Style::new() }; - let action = match &p.review_action { - voro_core::ReviewAction::Auto => String::new(), - other => format!(" [{other}]"), + let viewer = match &p.viewer { + Some(name) => format!(" [viewer:{name}]"), + None => String::new(), }; let archived = if p.archived { " [archived]" } else { "" }; // The path column shows the default repo, so a single-repo project @@ -1585,7 +1587,7 @@ fn draw_projects(frame: &mut Frame, app: &App, hits: &mut HitMap) { }; ListItem::new(Line::from(Span::styled( format!( - "{:>2} {:14} {:28} {} open{extra}{action}{archived}", + "{:>2} {:14} {:28} {} open{extra}{viewer}{archived}", p.weight, p.name, app.project_path(p.id), @@ -1876,7 +1878,7 @@ fn hint_candidates(app: &App) -> Vec<(&'static str, &'static str, bool)> { ("a", "add", true), ("A", "archive", true), ("d", "delete", true), - ("v", "review action", true), + ("v", "viewer", true), ("?", "keys", true), ("tab", "config", true), ("q", "quit", true), @@ -2054,7 +2056,7 @@ fn key_map(screen: Screen) -> Vec { ("a", "add a project"), ("A", "archive or unarchive the project"), ("d", "delete the project — only when it is empty"), - ("v", "pick the project's review action"), + ("v", "pick the project's viewer"), ], ), ( diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 64cb99d..ada43a4 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -75,9 +75,9 @@ CREATE TABLE projects ( archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0,1)), -- 1 = retired: the project and all its tasks -- leave the cockpit until unarchived (below) - review_action TEXT -- which viewer `open` uses for this project (§8): - -- 'viewer:name' names a voro.toml viewer; NULL (auto) - -- and 'pr' both leave the default viewer + viewer TEXT -- which viewer `open` uses for this project (§8): + -- the name of a voro.toml [viewers.] table, + -- or NULL for the default viewer ); CREATE TABLE repos ( -- execution targets under a project (§3) @@ -173,9 +173,9 @@ The **docs** tables (§3) are purely additive — no existing row changes shape A project that has stopped mattering is **archived** rather than deleted: `voro project archive` (and the projects screen's `A` key) sets the flag, and every cockpit view — the queue, `voro next`, the state counts and `stats`, the running strip — excludes the project and *all* of its tasks, whatever state each holds. This is retirement, not a transition: no task is moved or closed, the event log is untouched, and unarchiving restores the pre-archive view exactly. It is deliberately distinct from weight 0, which is a snooze — a parked project is expected back and its row sits untagged among the rest — whereas an archived project remains only on the projects screen and `voro project list`, dimmed under an `[archived]` tag, so it can be found and unarchived. The flag also closes the side doors: dispatch and redispatch refuse a task in an archived project, and `add`/`propose`/import refuse to create new work there — the refusals live in `voro-core` beside the human-task guards, so no interface can smuggle work into a retired project. Deleting a project outright stays reserved for one with no tasks at all; removing a project *and* its history is a separate, deliberate purge. -Agent definitions are command templates, not state, so they live outside the database. Voro *owns* the common ones — `claude` and `codex` are compiled into `voro-core`, so they version with the binary and every upgrade carries the current verb set (the session verbs of §8) to every install with no re-init. The user's `~/.config/voro/voro.toml` is then layered on top and is for extensions, overrides, and app options: it may add a new agent, replace a built-in wholesale (a `[agents.claude]` table overrides the built-in claude *entirely*, not per-verb — predictable over a partial merge), and set `default_agent` and the viewers. Viewers are command templates too, and live in the same file for the same reason: named `[viewers.]` tables define how a task's diff is shown locally (§8/§11a), `default_viewer` names the one used when nothing picks a viewer by name, and the older single anonymous `[viewer]` table stays valid as that default (a sole named viewer also serves as the default without being named). Which viewer a *project* uses is state, so it lives in the database (`projects.review_action`, §8), referencing these templates by name — which, since the review keys split (§8), is all that setting still decides. A viewer command carries up to three optional placeholders (§8): `{path}` — the task's worktree, or the project checkout when it has none — plus `{branch}` (the task's branch, empty when it has none) and `{base}` (the checkout's default branch), so `{base}...{branch}` spells the review diff's range rather than opening a bare directory. An agent table may also carry a small **model map** beside its verbs — `model`, `model_deep`, and `model_plan` — whose values fill the `{model}` placeholder in the `dispatch` and `plan` templates (§8). They are plain strings, opaque to Voro, which is why they live in the same file as the templates they are pasted into rather than in the schema: the model is part of how a command is spelled, not state about a task. Two further placeholders in those templates are filled from the launch rather than from this file: `{session_name}`, the name Voro composes for the session a launch opens, and `{task_id}`, the task's numeric id. Like `{model}` they are meaningful only where a command starts work, so both are refused on the session verbs, and `{task_id}` on `plan` as well, whose target may be a project with no task to name (§8). It also carries the queue's two pricing options — `max_running`, the dispatch WIP cap, and a `[costs]` table overriding the per-action attention divisors (§7) — for the same reason the viewers live here: they are operator preference about how the tool behaves, not state about a task, and a divisor is meaningless to anything but the rendering of the queue. Both are optional and both are validated at load, since a non-positive divisor or a negative cap would produce a nonsense order rather than an obvious error. Because it carries app options like the viewers and not just agents, the file is named `voro.toml`. A missing file is not an error; the built-ins alone are a working config, so a fresh install with `claude` on PATH dispatches without any TOML. `voro agent list` shows the effective set with each agent's provenance — built-in, user, or user-override — and warns when a user override of a built-in drops verbs the built-in defined, the one staleness case layering cannot fix; `voro viewer list` does the same for viewers, flagging the default. +Agent definitions are command templates, not state, so they live outside the database. Voro *owns* the common ones — `claude` and `codex` are compiled into `voro-core`, so they version with the binary and every upgrade carries the current verb set (the session verbs of §8) to every install with no re-init. The user's `~/.config/voro/voro.toml` is then layered on top and is for extensions, overrides, and app options: it may add a new agent, replace a built-in wholesale (a `[agents.claude]` table overrides the built-in claude *entirely*, not per-verb — predictable over a partial merge), and set `default_agent` and the viewers. Viewers are command templates too, and live in the same file for the same reason: named `[viewers.]` tables define how a task's diff is shown locally (§8/§11a), `default_viewer` names the one used when nothing picks a viewer by name, and the older single anonymous `[viewer]` table stays valid as that default (a sole named viewer also serves as the default without being named). Which viewer a *project* uses is state, so it lives in the database (`projects.viewer`, §8), naming one of these templates — which, since the review keys split (§8), is all that setting decides, and is why the column holds a viewer name and nothing else. A viewer command carries up to three optional placeholders (§8): `{path}` — the task's worktree, or the project checkout when it has none — plus `{branch}` (the task's branch, empty when it has none) and `{base}` (the checkout's default branch), so `{base}...{branch}` spells the review diff's range rather than opening a bare directory. An agent table may also carry a small **model map** beside its verbs — `model`, `model_deep`, and `model_plan` — whose values fill the `{model}` placeholder in the `dispatch` and `plan` templates (§8). They are plain strings, opaque to Voro, which is why they live in the same file as the templates they are pasted into rather than in the schema: the model is part of how a command is spelled, not state about a task. Two further placeholders in those templates are filled from the launch rather than from this file: `{session_name}`, the name Voro composes for the session a launch opens, and `{task_id}`, the task's numeric id. Like `{model}` they are meaningful only where a command starts work, so both are refused on the session verbs, and `{task_id}` on `plan` as well, whose target may be a project with no task to name (§8). It also carries the queue's two pricing options — `max_running`, the dispatch WIP cap, and a `[costs]` table overriding the per-action attention divisors (§7) — for the same reason the viewers live here: they are operator preference about how the tool behaves, not state about a task, and a divisor is meaningless to anything but the rendering of the queue. Both are optional and both are validated at load, since a non-positive divisor or a negative cap would produce a nonsense order rather than an obvious error. Because it carries app options like the viewers and not just agents, the file is named `voro.toml`. A missing file is not an error; the built-ins alone are a working config, so a fresh install with `claude` on PATH dispatches without any TOML. `voro agent list` shows the effective set with each agent's provenance — built-in, user, or user-override — and warns when a user override of a built-in drops verbs the built-in defined, the one staleness case layering cannot fix; `voro viewer list` does the same for viewers, flagging the default. -The file is no longer read-only to Voro. The TUI's Config screen (§9) and the `voro viewer add`/`viewer remove` verbs *edit* it in place — adding, changing, and deleting `[viewers.]` tables and setting `default_viewer`/`default_agent` — through a single write helper (`voro-core::config_edit`) built on `toml_edit`, so a machine write preserves the file's existing content, formatting, and comments and touches only the key it changes. A missing file is created on first edit. The user-owned surface is all that is writable this way: agents stay read-only in the TUI (editing a built-in means writing a wholesale override table, a sharper knife deferred here), and deleting a viewer that a project's `review_action` still names as `viewer:` is refused with the projects named, while deleting the default viewer clears `default_viewer`. +The file is no longer read-only to Voro. The TUI's Config screen (§9) and the `voro viewer add`/`viewer remove` verbs *edit* it in place — adding, changing, and deleting `[viewers.]` tables and setting `default_viewer`/`default_agent` — through a single write helper (`voro-core::config_edit`) built on `toml_edit`, so a machine write preserves the file's existing content, formatting, and comments and touches only the key it changes. A missing file is created on first edit. The user-owned surface is all that is writable this way: agents stay read-only in the TUI (editing a built-in means writing a wholesale override table, a sharper knife deferred here), and deleting a viewer that a project's `viewer` still names is refused with the projects named, while deleting the default viewer clears `default_viewer`. ```toml # ~/.config/voro/voro.toml — all optional; extends/overrides the built-in claude and codex @@ -328,7 +328,7 @@ Naming the id literally is what makes the return path survive the launch style t **What the row advertises follows the checkout, even though the keys do not.** Static keys settle what `g` and `o` *do*; they say nothing about which of the two a row should recommend, and a review row in a project with nowhere to push was recommending `pr` — an action whose only possible outcome there is the refusal above. A first project is very often a bare `git init`, so the operator most likely to trust the recommendation is the one it fails for. The advertised verb therefore degrades: on a `review` task with no tracked PR whose checkout has no git remote, every surface that names a next action — the cockpit's detail card, the browser and `list` suffixes, `show`, and the `inbox` verb column — reads `open` instead of `pr`, and the card's hint names `o` rather than `g`. Only the advertisement moves; the keys, the create-PR flow, and its refusal are exactly as above, and the attention price is unchanged because reading a diff costs the same whatever medium it arrives on (§7). The question the advertisement asks is deliberately blunter than the one `pr` asks at press time. `pr` asks `gh` whether this is a GitHub repository and pays a round-trip for the answer; the advertisement rides a rendered row, so it must be network-free and cheap, and it asks git alone whether the checkout has *any* remote — a repository with nowhere to push has no forge to open a pull request on, whichever forge that would have been. The answers can only differ one way: a checkout whose remote is not GitHub still advertises `pr` and is still refused at press time, which is the dead end that already existed rather than a new one. Anything git cannot answer reads as "yes", so no row moves on a guess. One `git remote` per distinct checkout is memoised per render pass, and in the TUI it is derived in the same refresh that derives the `[incomplete report]` flag rather than on the draw path. -The **review action** (`projects.review_action`, §5) survives this with a narrower job: it names *which viewer* a project's local diffs open in, not which medium the review key uses. `viewer:` is the only form that now says anything — it picks a `[viewers.]` table (§11a) — while `auto` and `pr` both mean "no viewer named, use the default", which makes them behaviourally identical and leaves `pr` a stored value kept only so a project pinned before the split still reads and writes. The setting keeps its full surface — the schema column, `voro project action`, the projects screen's `v` picker — because naming a per-project viewer is worth keeping; what it lost is the medium decision, and with it the `gh repo view` probe behind `auto`. Because a dispatched agent works in a throwaway worktree on the task's branch (§11), the diff lives there, not in the primary checkout, so `open` runs the viewer in that worktree when the task's branch has a live one, falling back to the task's resolved repo (§3) when it has no branch or no worktree. The viewer template is filled with `{path}` (that resolved directory), `{branch}` (the task's branch, empty when none), and `{base}` (the checkout's default branch, read from `refs/remotes/origin/HEAD` with a `main` fallback) so it can express a diff range like `{base}...{branch}` rather than a bare directory; a template using none of these is substituted unchanged. The action is set per project with `voro project action` or the projects screen's picker (`v`), and viewers are defined as `[viewers.]` tables in `voro.toml` (§5) surfaced by `voro viewer list`. The pure precondition check and plan assembly live in `voro-core` (tested); the seam supplies only the git/`gh` I/O, in the `voro` crate. +The **project's viewer** (`projects.viewer`, §5) is what survives this, and it is now shaped like what it does: it names *which viewer* a project's local diffs open in, so the stored value is a viewer name — a `[viewers.]` table (§11a) — or nothing at all, which is the default viewer. The pre-split spellings collapsed into that (an additive migration, §5): `viewer:` kept its name, while `auto`, `pr`, and a bare `viewer` were three ways of saying "name no viewer" and are now NULL. Nothing was lost in the collapse, because the medium decision had already gone with the keys, and the `gh repo view` probe behind `auto` with it — what remained was a type carrying three spellings of one behaviour, which is a type that has to be read twice before a value can be trusted. The surface it keeps is the whole surface — the schema column, `voro project viewer`, the projects screen's `v` picker — because naming a per-project viewer is worth keeping. Because a dispatched agent works in a throwaway worktree on the task's branch (§11), the diff lives there, not in the primary checkout, so `open` runs the viewer in that worktree when the task's branch has a live one, falling back to the task's resolved repo (§3) when it has no branch or no worktree. The viewer template is filled with `{path}` (that resolved directory), `{branch}` (the task's branch, empty when none), and `{base}` (the checkout's default branch, read from `refs/remotes/origin/HEAD` with a `main` fallback) so it can express a diff range like `{base}...{branch}` rather than a bare directory; a template using none of these is substituted unchanged. The viewer is set per project with `voro project viewer` — naming none falls back to the config's `default_viewer` — or the projects screen's picker (`v`), and viewers are defined as `[viewers.]` tables in `voro.toml` (§5) surfaced by `voro viewer list`. The pure precondition check and plan assembly live in `voro-core` (tested); the seam supplies only the git/`gh` I/O, in the `voro` crate. **Re-reviewing after a rejection** is `pr`'s third job, and exists because rejection was priced wrongly. Sending work back cost the operator a second full review: `pr` and `open` reopened the whole diff, and the context of *what they had asked for* was gone by the time the rework came back, so they rediscovered their own feedback from the code. A rejection that expensive is a rejection not made — the operator accepts marginal work rather than pay for the round trip — which is the opposite of what the review state is for. So a re-review is made proportional to the fix rather than to the branch, on Gerrit's patchset model: show the diff *since the revision that was rejected*. The revision is captured at the one moment it is unambiguous — the rejection itself, where the branch head is exactly what the operator just judged — and recorded as a `reviewed` event. The event log carries it because the log is already the record of every mutation and this is one more; it needs no column and no migration, and a second rejection supersedes the first the way a second summary supersedes the first. Deliberately *not* recorded when a task merely enters `review`: the head at that moment is the head the operator is about to look at, so a delta against it would be empty, and a first review has nothing to compare against anyway. Which revision gets recorded depends on where the review happened — a tracked PR's head (`gh pr view --json headRefOid`), since that is literally what was on the screen, falling back to the local tip of the task's branch for a task without one. The whole capture is best-effort: an unreadable revision costs a full diff next time, never a failed reject. Where it runs from decides whether it blocks, by the rule below: `voro reject` is a one-shot CLI verb, so it resolves the revision and records it synchronously, while both of the TUI's rejections — the transition menu and the quick-message key — hand the `gh` call to a background thread and record what it sends back a tick or two later. The rejected task is therefore briefly in `running` with no revision recorded against it, which nothing reads (both read paths consult it only for a task in `review`, and the rework comes back minutes or hours later), and quitting the TUI before the capture lands simply loses it for the full diff that failure already degrades to. @@ -348,7 +348,7 @@ Because these are plain CLI calls writing to a local SQLite file, they work iden The return path depends on the agent remembering to call it, and for Claude Code — the one agent with richer integration points than a shell command — its lifecycle hooks are a belt-and-braces layer under that discipline, calling `voro done`/`ask` on a session that forgets. This needs no new machinery: hooks inherit the session's `VORO_TASK_ID`/`VORO_DB`, and the transition API's rejection of any illegal second transition — writing nothing, committing nothing — is the whole of the double-transition protection, so a hook and the reconciler cannot corrupt each other whichever lands first (a hook's late `done` on a reconciled task completes it `stalled → review`, the same place the other order reaches). There is deliberately no failure hook: a crash or usage-cap `SIGKILL` bypasses `SessionEnd`, so hard failure stays with the reconciler by design. The concrete hooks, wrapper scripts, and sample `.claude/settings.json` — with the `CLAUDE.md`/`AGENTS.md` return-path snippet — are per-agent glue, not core, and live in [`agent-integration.md`](agent-integration.md). -Dispatch runs in the **task's resolved repo** (§3/§5) — its own repo when it names one, the project's default otherwise — which must be a git repository; the dispatched agent does its work in a throwaway worktree it creates (the preamble instructs this), so the operator's uncommitted changes never enter its diff. Everything downstream of the dispatch resolves the same way, through the same helper: the git guard and the spawn's cwd, the session-ref capture, `pr`'s push and `gh pr create`, `open`'s worktree lookup and `{base}` branch, and the accept-time worktree cleanup all read the *task's* repo rather than the project's default, because a task dispatched into a second repo has its branch, its worktree, and its PR there. The per-project review action (`projects.review_action`) is untouched by this: it names a viewer for the project, and the task's checkout is what that viewer is pointed at, so a multi-repo project needs nothing configured twice. The GitHub check `pr` now makes unconditionally — "can this checkout take a pull request at all?" — runs against the task's checkout for the same reason. Two consumers deliberately stay on the default repo, because neither executes a task: a planning session (`N`) runs in the default checkout and the task it drafts picks its own repo with `voro add --repo`, and `voro import` defaults there while taking `--repo ` to import from another (the tasks it creates then carry that repo, so an imported issue dispatches where it lives). Voro-managed per-dispatch worktrees are deferred until parallel dispatch within one project is actually wanted (§11). +Dispatch runs in the **task's resolved repo** (§3/§5) — its own repo when it names one, the project's default otherwise — which must be a git repository; the dispatched agent does its work in a throwaway worktree it creates (the preamble instructs this), so the operator's uncommitted changes never enter its diff. Everything downstream of the dispatch resolves the same way, through the same helper: the git guard and the spawn's cwd, the session-ref capture, `pr`'s push and `gh pr create`, `open`'s worktree lookup and `{base}` branch, and the accept-time worktree cleanup all read the *task's* repo rather than the project's default, because a task dispatched into a second repo has its branch, its worktree, and its PR there. The per-project viewer (`projects.viewer`) is untouched by this: it names a viewer for the project, and the task's checkout is what that viewer is pointed at, so a multi-repo project needs nothing configured twice. The GitHub check `pr` now makes unconditionally — "can this checkout take a pull request at all?" — runs against the task's checkout for the same reason. Two consumers deliberately stay on the default repo, because neither executes a task: a planning session (`N`) runs in the default checkout and the task it drafts picks its own repo with `voro add --repo`, and `voro import` defaults there while taking `--repo ` to import from another (the tasks it creates then carry that repo, so an imported issue dispatches where it lives). Voro-managed per-dispatch worktrees are deferred until parallel dispatch within one project is actually wanted (§11). **Planning sessions** are the same machinery pointed at the *front* of a task's life: agent-assisted task creation, where the operator plans a task interactively with an agent and the deliverable of the session is a Voro task, not a PR. This is TUI-only by design — the CLI is how an LLM drives Voro, so an LLM-drafting verb there would be circular — and it is interactive by design: a one-shot variant (agent expands a description into a pre-filled editor form) was considered and rejected, because task planning is usually a back-and-forth and an interactive session subsumes the one-shot case (say what you want, confirm, exit). From the TUI, `N` (beside `n`'s manual editor, which stays first-class — the same lowercase-default, uppercase-variant pairing as `d`/`D`) picks a project and suspends the terminal in the same round-trip used for `$EDITOR` and attach/resume, launching the default agent's **`plan` verb** in the project's default repo (§3): an optional agent template alongside dispatch/sessions/attach/resume/message — an interactive *foreground* command carrying `{prompt_file}`, built in for `claude` — that degrades like the other optional verbs, an agent without one yielding a status line saying what to configure. The prompt seeds the session with its job: it is drafting a task for that project; interview the operator as needed; write the body as a self-contained dispatchable prompt (named files, acceptance criteria); and when the operator confirms, create the task with `voro add` — the CLI is the agent's interface exactly as in dispatch, down to the rendered `--db` flag for a non-default store, so Voro gains no new store write path and parses no agent output. When the session exits the TUI refreshes, and the new task appears in the queue as `proposed` for ordinary triage — the human already saw the content, but triage stays uniform. A session that exits without creating a task is a no-op, not an error; no session row is recorded and none of dispatch's guards apply, since planning only reads the checkout and writes nothing to it. The built-in claude verbs reach their per-purpose models through the `{model}` map above — a stronger reasoning model on `plan` and on a deep dispatch, a workhorse on an ordinary one — naming the `claude` model aliases (`fable`, `opus`) rather than pinned ids so they track the current model of each class without churning; an operator overrides the agent wholesale in `voro.toml` to change them (docs/agent-integration.md). The same session serves the *middle* of a proposal's life as well: an interactive refine (§6) is this exact machinery pointed at a task that already exists — same `plan` verb, same foreground round-trip — seeded with the current body and ending in `voro set --body-file` instead of `voro add`, so it rewrites in place rather than creating anything. Where the two part company is bookkeeping, and the reason is that one has a task and the other does not: a refine is a round on an existing proposal, so it moves that task's state and records a session, which is why the round-trip *spawns* its child rather than simply running it to completion — the pid it learns is what the session row carries and what another window's reconcile probes if this Voro dies mid-conversation. A `Create` session has no task to transition and so records nothing at all. A planning session runs in the project's default repo because the task it drafts has not chosen one; a refine runs in the task's *resolved* repo, since the code its body must name is there. The verb roster stops at `dispatch`/`sessions`/`attach`/`resume`/`message`/`plan`: an `expand` verb for the headless refine was considered and rejected, because it would have differed from `dispatch` only in the session name and the model — arguments, not verbs — and every third-party agent defining only `dispatch` would have stopped refining until its config gained one. A launch flavour is an argument. The two launching verbs that do stay distinct differ by *mode of interaction*, detached versus owning the terminal, which is a real difference in the process contract rather than a difference of label. @@ -364,7 +364,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. An operator who navigates back to an empty cockpit meets the same fact there: with no projects the empty queue points at the projects screen rather than at `n`, in the same words `n` itself refuses in. -Beyond the cockpit, the TUI cycles (Tab, or `1`–`4`) through three further full-screen views: the **task browser**, the **projects screen** (weights, archive, and the per-project review action), 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. DB-backed configuration (projects, weights, review actions) stays on the projects screen; the Config screen is the voro.toml view. The projects screen's review-action 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 `1`–`4`) 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. 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. **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` are reachable and documented without ever crowding the line — so `?` itself is the one key every screen's line always carries. The two review keys are the deliberate exception to "state or destiny only": `o` (the local diff) and `g` (the PR) change nothing, but on a task that has just come back for review, looking at the diff *is* the operator's next action, and the line is where the moment is announced. They earn the slots by being tightly gated on that moment — `o` on `review` or `running`, the two states with work to look at, and `g` on `review` alone — so they are absent from the line everywhere the argument for them does not hold, even though both keys stay bound in every state (§8: `g` also jumps to a tracked PR, and links one). The lesson generalises in both directions: a read-only key belongs on the line when the selection's state makes it the obvious next press, and a state-changing one drops off it when that state has passed. `!` (deep) is the second half of that — it picks the model of the *next* dispatch, so on a task under review, handed off, or closed it toggles something the operator is not about to see, and the review row reclaims the slot. Neither narrowing touches what the keys *do*: `!`, `o`, and `g` stay bound in every state, and it is only the advertisement that follows the selection. The line's budget is eleven slots, one more than it held before the review cluster arrived, and a `review` row — `⏎ review`, `w wait`, `o open`, `g PR`, and the unconditional rest — is the row that spends them all. @@ -394,7 +394,7 @@ Ordered by dependency and by time-to-useful, not by calendar — with agents doi ## 11. Open questions -Voro-managed worktrees per dispatch, or let the agent make its own? The dispatched agent creates its own throwaway worktree (the dispatch preamble instructs it, §8), and Voro-owned per-dispatch worktrees are deferred until parallel dispatch within a single project is genuinely wanted. How `review` gets its diff in front of you is resolved as layered surfacing folded into the per-project review action (§8), not an inline diff pane. Three complementary paths, none exclusive: (a) a configurable viewer command run on `review`/`running` rows — the `[viewers.*]` templates and `open`, the editor-agnostic baseline; (b) a `git diff --stat` summary in the detail pane so the queue carries a completed diff's size without leaving the TUI (the git call lives in the `voro` crate, keeping `voro-core` process-free); (c) optional tracking of a GitHub PR on the task (`pr_url`), so the review action can jump to where the diff and its review comments already live, and a tracked PR's comments can become the reject-with-feedback body (§6) without retyping. The mechanics live in §8; the inline diff pane and a live IDE-connect spike stay deferred to Milestone D behind that baseline. Session-log retention is settled for now at keeping the full log at `log_path` indefinitely — a single-user session's log is a few MB at most, and the tail is already read back for usage-cap detection and a redispatch's predecessor notes (§8), so trimming would only have to be reversed; revisit if logs ever grow enough to cost something. Do human tasks (§3) eventually need context/availability tags — `@robot`, `@errands`-style GTD contexts marking *where* or *when* the human can execute them? Deliberately deferred: the human flag alone keeps the queue honest about what an agent can pick up, and a context taxonomy only earns its complexity once hands-on rows measurably clutter desk-time use of the queue. If that bites, tags would be a filter over the same score, not a new scheduling input. +Voro-managed worktrees per dispatch, or let the agent make its own? The dispatched agent creates its own throwaway worktree (the dispatch preamble instructs it, §8), and Voro-owned per-dispatch worktrees are deferred until parallel dispatch within a single project is genuinely wanted. How `review` gets its diff in front of you is resolved as layered surfacing folded into the per-project viewer (§8), not an inline diff pane. Three complementary paths, none exclusive: (a) a configurable viewer command run on `review`/`running` rows — the `[viewers.*]` templates and `open`, the editor-agnostic baseline; (b) a `git diff --stat` summary in the detail pane so the queue carries a completed diff's size without leaving the TUI (the git call lives in the `voro` crate, keeping `voro-core` process-free); (c) optional tracking of a GitHub PR on the task (`pr_url`), so `pr` can jump to where the diff and its review comments already live, and a tracked PR's comments can become the reject-with-feedback body (§6) without retyping. The mechanics live in §8; the inline diff pane and a live IDE-connect spike stay deferred to Milestone D behind that baseline. Session-log retention is settled for now at keeping the full log at `log_path` indefinitely — a single-user session's log is a few MB at most, and the tail is already read back for usage-cap detection and a redispatch's predecessor notes (§8), so trimming would only have to be reversed; revisit if logs ever grow enough to cost something. Do human tasks (§3) eventually need context/availability tags — `@robot`, `@errands`-style GTD contexts marking *where* or *when* the human can execute them? Deliberately deferred: the human flag alone keeps the queue honest about what an agent can pick up, and a context taxonomy only earns its complexity once hands-on rows measurably clutter desk-time use of the queue. If that bites, tags would be a filter over the same score, not a new scheduling input. ## 12. Risks