diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b43b43..7bc26df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **The project picker offers only projects that can take the work, weightiest + first.** It used to list every project alphabetically, archived ones included + — and an archived project refuses new tasks, so picking one could only fail, + which in the `$EDITOR` and planning flows it did only after you had written + the task out. Archived projects are now dropped from the picker entirely + (unarchiving stays where it was, on the projects screen), and the rest are + ordered by the weight you set every morning, each row showing it, so the + project you are actually working on is the one under the cursor. A parked + project is still offered and simply sorts last. One live project beside + archived ones now skips the picker and creates straight into it, and with + every project archived `n`/`N` say so instead of raising an empty list. - **The key line stops offering `o` and `g` on a review task with nothing to show.** A task whose whole product is its summary — an investigation, a triage, an audit — reaches `review` having never made a branch, and both keys diff --git a/crates/voro-core/src/lib.rs b/crates/voro-core/src/lib.rs index 02da211..8b4063e 100644 --- a/crates/voro-core/src/lib.rs +++ b/crates/voro-core/src/lib.rs @@ -30,6 +30,7 @@ pub use import::{GithubIssue, already_imported, issue_new_task, issue_task_body} pub use model::{ Dep, DepKind, DepRef, Doc, Event, LivenessSource, NextAction, Priority, Project, RefineOutcome, Repo, RunningRow, Session, SessionOutcome, Task, TaskState, location_is_url, + projects_for_new_task, }; 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 6446372..97a1d77 100644 --- a/crates/voro-core/src/model.rs +++ b/crates/voro-core/src/model.rs @@ -378,6 +378,20 @@ pub struct Project { pub archived: bool, } +/// The projects a new task can be created in, in the order to offer them +/// (DESIGN.md §9). Archived projects are dropped — `Store::create_task` refuses +/// them (§5), so offering one is offering a choice that can only fail, and in +/// the `$EDITOR` and planning flows it fails only after the operator has +/// written the task out. The rest sort by weight descending, which is the one +/// per-project priority Voro holds (§7), with name ascending inside a weight so +/// the order is stable. Weight 0 is a snooze rather than a retirement, so a +/// parked project stays offered and sorts last. +pub fn projects_for_new_task(projects: &[Project]) -> Vec<&Project> { + let mut offered: Vec<&Project> = projects.iter().filter(|p| !p.archived).collect(); + offered.sort_by(|a, b| b.weight.cmp(&a.weight).then_with(|| a.name.cmp(&b.name))); + offered +} + /// A checkout a project's work runs in (DESIGN.md §3): the execution target /// dispatch, `pr`/`open`, worktree cleanup, and `import` resolve against. A /// project owns at least one, exactly one of which is its default. @@ -653,6 +667,45 @@ mod tests { assert_eq!(format!("{:>6}", Priority::P2), " P2"); } + fn project(name: &str, weight: i64, archived: bool) -> Project { + Project { + id: 1, + name: name.into(), + weight, + viewer: None, + archived, + } + } + + #[test] + fn new_task_projects_drop_the_archived_at_any_weight() { + let projects = [ + project("live", 1, false), + project("retired-heavy", 5, true), + project("retired-parked", 0, true), + ]; + let offered = projects_for_new_task(&projects); + assert_eq!( + offered.iter().map(|p| p.name.as_str()).collect::>(), + ["live"] + ); + } + + #[test] + fn new_task_projects_sort_by_weight_then_name() { + let projects = [ + project("beta", 3, false), + project("parked", 0, false), + project("alpha", 3, false), + project("heaviest", 5, false), + ]; + let offered = projects_for_new_task(&projects); + assert_eq!( + offered.iter().map(|p| p.name.as_str()).collect::>(), + ["heaviest", "alpha", "beta", "parked"] + ); + } + fn task_in(state: TaskState, pr_url: Option<&str>, human: bool) -> Task { Task { id: 1, diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index 9b09c09..8ada817 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -4,7 +4,8 @@ use crate::ui::Hit; use voro_core::{ Action, ActionRow, AgentsConfig, CompletionReport, DepKind, DepRef, DigestRow, Event, LivenessSource, PrRef, Priority, Project, Queue, QueueRow, RefineOutcome, RunningRow, - ScoreBreakdown, StateCounts, Store, Task, TaskState, Triage, WipGate, scheduler, + ScoreBreakdown, StateCounts, Store, Task, TaskState, Triage, WipGate, projects_for_new_task, + scheduler, }; /// Lines `PgDn`/`PgUp` move the focus card in one press. A fixed step, since @@ -16,6 +17,12 @@ const DETAIL_PAGE_STEP: i64 = 10; /// bound on, so this is a defensive path rather than one they can reach. pub const NO_PROJECTS_HINT: &str = "no projects yet — press tab to Projects, then a to add one"; +/// `n`'s refusal when every registered project is archived. An archived project +/// refuses new work (DESIGN.md §5), so there is nothing to pick; unarchiving is +/// the projects screen's job, which is where this points. +pub const ALL_PROJECTS_ARCHIVED_HINT: &str = + "every project is archived — press tab to Projects, then A to unarchive one"; + /// How a gated screen jump refuses (DESIGN.md §9). It names `alt-3` rather than /// `tab` because the jump is the shortest route from either screen the gate /// leaves reachable, and it lands on Projects from both. @@ -1840,14 +1847,24 @@ impl App { .is_some_and(|t| t.state == TaskState::Refining) } - /// Begin creating a task in one of the two flows (DESIGN.md §9): straight - /// into it when there is exactly one project, via the project picker when - /// there are several, and a pointer to the projects screen when there are - /// none. + /// The projects the create flows offer, in the order they offer them + /// (DESIGN.md §9) — unarchived only, weightiest first. Both the picker's + /// key handler and its draw arm index this, so `sel` means the same thing + /// to each. + pub fn creatable_projects(&self) -> Vec<&Project> { + projects_for_new_task(&self.projects) + } + + /// Begin creating a task in one of the three flows (DESIGN.md §9): straight + /// into it when exactly one project can take work, via the project picker + /// when several can, and a pointer to the projects screen when none can — + /// because none is registered, or because every one of them is archived. fn new_task(&mut self, flow: CreateFlow) { - match self.projects.len() { - 0 => self.status = Some(NO_PROJECTS_HINT.into()), - 1 => self.start_create(self.projects[0].id, flow), + let offered: Vec = self.creatable_projects().iter().map(|p| p.id).collect(); + match offered.len() { + 0 if self.projects.is_empty() => self.status = Some(NO_PROJECTS_HINT.into()), + 0 => self.status = Some(ALL_PROJECTS_ARCHIVED_HINT.into()), + 1 => self.start_create(offered[0], flow), _ => self.mode = Mode::PickProject { sel: 0, flow }, } } @@ -3538,12 +3555,12 @@ impl App { match key.code { KeyCode::Esc => return, KeyCode::Char('j') | KeyCode::Down => { - sel = (sel + 1).min(self.projects.len().saturating_sub(1)); + sel = (sel + 1).min(self.creatable_projects().len().saturating_sub(1)); } KeyCode::Char('k') | KeyCode::Up => sel = sel.saturating_sub(1), KeyCode::Enter => { - if let Some(project) = self.projects.get(sel) { - self.start_create(project.id, flow); + if let Some(project_id) = self.creatable_projects().get(sel).map(|p| p.id) { + self.start_create(project_id, flow); } return; } @@ -7411,6 +7428,86 @@ mod tests { assert!(written_prompts(&app).is_empty(), "nothing spawns yet"); } + /// An archived project cannot take a new task at all (DESIGN.md §5), so it + /// is not a candidate: with one live project beside three archived ones + /// there is nothing to pick between, and `n` opens the create flow on the + /// live one rather than a picker whose other rows can only fail. + #[test] + fn create_skips_the_picker_when_only_one_project_is_unarchived() { + let mut app = app_with_stub_dispatch(); + let live = app.projects[0].id; + for name in ["retired-a", "retired-b", "retired-c"] { + let p = app + .store + .create_project(name, &format!("/tmp/{name}")) + .unwrap(); + app.store.set_archived(p.id, true).unwrap(); + } + app.refresh().unwrap(); + assert_eq!(app.projects.len(), 4); + + key(&mut app, KeyCode::Char('n')); + match &app.mode { + Mode::QuickCreate { project_id, .. } => assert_eq!(*project_id, live), + _ => panic!("n should open the quick-create modal"), + } + } + + /// With every project archived there is no project to create in, and no + /// picker to open over none. It refuses the way the neighbouring keys do — + /// a no-op with an explanation, pointing at the screen that unarchives. + #[test] + fn create_with_every_project_archived_explains_itself() { + let mut app = app_with_stub_dispatch(); + let only = app.projects[0].id; + app.store.set_archived(only, true).unwrap(); + app.refresh().unwrap(); + + for press in ['n', 'N'] { + app.status = None; + key(&mut app, KeyCode::Char(press)); + assert!(matches!(app.mode, Mode::Normal), "no picker opens"); + assert_eq!(app.status.as_deref(), Some(ALL_PROJECTS_ARCHIVED_HINT)); + assert!(app.pending_editor.is_none()); + assert!(app.pending_plan.is_none()); + } + } + + /// The picker offers what can take work in the order the operator ranked it + /// — weight descending, name ascending inside a weight (DESIGN.md §9) — so + /// ⏎ on a row starts the create flow on the project *that* order puts + /// there, not the one alphabetical order would have. + #[test] + fn picker_rows_follow_weight_then_name() { + let mut app = app_with_stub_dispatch(); + let demo = app.projects[0].id; + app.store.set_weight(demo, 1).unwrap(); + let heavy = app.store.create_project("zeta", "/tmp/zeta").unwrap(); + app.store.set_weight(heavy.id, 4).unwrap(); + let parked = app.store.create_project("alpha", "/tmp/alpha").unwrap(); + app.store.set_weight(parked.id, 0).unwrap(); + let hidden = app.store.create_project("beta", "/tmp/beta").unwrap(); + app.store.set_weight(hidden.id, 5).unwrap(); + app.store.set_archived(hidden.id, true).unwrap(); + app.refresh().unwrap(); + + // zeta (4), demo (1), alpha (0) — beta is archived and absent despite + // outweighing all three. + for (row, expected) in [(0, heavy.id), (1, demo), (2, parked.id)] { + key(&mut app, KeyCode::Char('n')); + assert!(matches!(app.mode, Mode::PickProject { sel: 0, .. })); + for _ in 0..row { + key(&mut app, KeyCode::Char('j')); + } + key(&mut app, KeyCode::Enter); + match &app.mode { + Mode::QuickCreate { project_id, .. } => assert_eq!(*project_id, expected, "{row}"), + _ => panic!("row {row} should open the modal"), + } + key(&mut app, KeyCode::Esc); + } + } + /// `ctrl-n` keeps the manual `$EDITOR` form, the only path that sets state, /// priority and dependencies at creation time (DESIGN.md §8). #[test] diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index 45c83a4..6f65a55 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -140,14 +140,17 @@ fn draw_mode(frame: &mut Frame, app: &App, hits: &mut HitMap) { frame.render_widget(para, area); } Mode::PickProject { sel, flow } => { + // Weight first, then name, as the projects screen lists them + // — the order is weightiest-first (DESIGN.md §9), which reads as + // arbitrary unless the number it sorts on is on the row. let items: Vec = app - .projects + .creatable_projects() .iter() - .map(|p| ListItem::new(p.name.clone())) + .map(|p| ListItem::new(format!("{:>2} {}", p.weight, p.name))) .collect(); let count = items.len(); let height = items.len() as u16 + 2; - let area = popup_area(frame, 44, height.max(3)); + let area = popup_area(frame, 48, height.max(3)); let mut state = ListState::default().with_selected(Some(*sel)); let title = match flow { crate::app::CreateFlow::Quick => "Project to propose a task in", @@ -2414,6 +2417,38 @@ mod tests { assert!(text.contains("nothing to do — press n"), "{text}"); } + /// The picker carries the weight it sorts on, so weightiest-first reads as + /// an order rather than a shuffle, and an archived project — which could + /// not take the task anyway (DESIGN.md §5) — is not on it at all. + #[test] + fn the_project_picker_shows_weights_and_hides_the_archived() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let mut app = app_with_status("", 0, 0); + let heavy = app.store.create_project("zeta", "/tmp/zeta").unwrap(); + app.store.set_weight(heavy.id, 4).unwrap(); + let retired = app.store.create_project("retired", "/tmp/retired").unwrap(); + app.store.set_weight(retired.id, 5).unwrap(); + app.store.set_archived(retired.id, true).unwrap(); + app.refresh().unwrap(); + app.mode = crate::app::Mode::PickProject { + sel: 0, + flow: crate::app::CreateFlow::Quick, + }; + + 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("4 zeta"), "{text}"); + assert!(text.contains("3 voro"), "{text}"); + assert!(!text.contains("retired"), "{text}"); + } + /// The key map may not advertise a jump the gate would refuse (DESIGN.md /// §9): with no project registered the Screens section drops `alt-1` and /// `alt-2`, and gets them back the moment one exists. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0839c7e..35d2ff9 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -393,7 +393,7 @@ A refine round (§6) rides the strip as its own kind of row — `⟳ refining`, A hand-off (§6) rides it the same way — `⏳ waiting`, elapsed from the hand-off rather than from the session, badged `blocks N` when open dependents are gated behind it and marked when a PR tracks it (presence only; Voro polls no PR state). Its row sorts below the work an agent is driving, and it carries no orphan warning, since a hand-off has nothing left to be live. The verdicts `waiting` offers — accept, reject with feedback, reclaim, abandon — are reached from the strip row through the same transition menu every other row uses, and the two session keys work on it as they do anywhere else: `a` sends a line into the still-open session, which *is* the rejection (§6), and `A` jumps into it, `waiting` keeping its session on exactly `review`'s terms (§8). The dispatch-oriented keys no-op with an explanation, as they do on a refine. -The cockpit is where the TUI opens, with one exception: a database with no projects registered opens on the projects screen instead, because that is where the first step is — nothing can be created until a project exists, and the cockpit has nothing to show until one does. The check runs once, at startup, against the project list the app already loads; every screen change after that is a key the operator pressed, so a refresh, a poll, or deleting the last project never moves them. **That landing is held by a gate rather than left to the first keypress:** until a project exists the TUI is a two-screen tool, Projects and Config, and the cockpit and the task browser cannot be entered at all. Pointing the operator at Projects and then letting Tab walk them straight off it bought nothing — the cockpit and the browser each had a full screen of content whose entire message was "not here", and two screens that exist only to say that are worse than two screens that cannot be reached. Config stays reachable throughout because it edits the `voro.toml` viewers and agents, which needs no project and is a legitimate place to be before registering one; the gate is about screens with nothing to show, not about a rule that nothing may be done first. Because the gate is expressed as a shorter Tab ring (Projects ↔ Config) and the projects screen binds no screen jumps, the only place a refusal can fire is the alt-digit jump to the cockpit or the browser, which no-ops with a status line naming the route to a project — the same shape as `n`'s own zero-project refusal. The `?` key map follows suit and stops advertising `alt-1` and `alt-2` while the gate holds, since a map that listed them would be promising a refusal. Adding the first project does not move the operator off the projects screen, but every screen is reachable from the next keypress on. The gate also settles what the two empty states used to say: with the cockpit and the browser unreachable without a project, each has exactly one case left to explain — a drained queue and a project with no tasks — and both point at `n`. +The cockpit is where the TUI opens, with one exception: a database with no projects registered opens on the projects screen instead, because that is where the first step is — nothing can be created until a project exists, and the cockpit has nothing to show until one does. The check runs once, at startup, against the project list the app already loads; every screen change after that is a key the operator pressed, so a refresh, a poll, or deleting the last project never moves them. **That landing is held by a gate rather than left to the first keypress:** until a project exists the TUI is a two-screen tool, Projects and Config, and the cockpit and the task browser cannot be entered at all. Pointing the operator at Projects and then letting Tab walk them straight off it bought nothing — the cockpit and the browser each had a full screen of content whose entire message was "not here", and two screens that exist only to say that are worse than two screens that cannot be reached. Config stays reachable throughout because it edits the `voro.toml` viewers and agents, which needs no project and is a legitimate place to be before registering one; the gate is about screens with nothing to show, not about a rule that nothing may be done first. Because the gate is expressed as a shorter Tab ring (Projects ↔ Config) and the projects screen binds no screen jumps, the only place a refusal can fire is the alt-digit jump to the cockpit or the browser, which no-ops with a status line naming the route to a project — the same shape as `n`'s own zero-project refusal. The `?` key map follows suit and stops advertising `alt-1` and `alt-2` while the gate holds, since a map that listed them would be promising a refusal. Adding the first project does not move the operator off the projects screen, but every screen is reachable from the next keypress on. The gate also settles what the two empty states used to say: with the cockpit and the browser unreachable without a project, each has exactly one case left to explain — a drained queue and a project with no tasks — and both point at `n`. The create keys ask *which* project only when there is a choice to be made, and they offer only projects that can take the task: an archived project refuses new work (§5), so it is dropped from the picker rather than listed there to fail — late, in the `$EDITOR` and planning flows, after the operator has already written the task out. What remains is ordered weightiest first, each row carrying the weight it sorts on, because project weight is the one per-project priority Voro holds (§7) and it is what the operator sets every morning, where alphabetical order says nothing about which project this week's work is in. A parked project stays on the list and simply sorts last, weight 0 being a snooze rather than a retirement. So a single unarchived project beside archived ones skips the picker entirely and creates straight into the live one, and a store whose every project is archived opens no picker at all, refusing with a status line pointing at the projects screen — the same shape as the zero-project refusal above. Beyond the cockpit, the TUI cycles (Tab, or `alt-1`–`alt-4`, subject to the gate above while no project is registered) through three further full-screen views: the **task browser**, the **projects screen** (weights, archive, and the per-project viewer), and a **Config screen** that renders and edits the `voro.toml` surface (§5) — the effective agents read-only with provenance and the default marked, and the named viewers editable in place (add, change command, delete, and pick `default_viewer`/`default_agent`) through the comment-preserving write helper. 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.