diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f73069..211d89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 may cite a document, since one plan routinely spawns work across several. An absolute path inside one of the project's checkouts is stored relative to it, so the link survives the checkout moving. +- Document links from the TUI: `c` on a selected task — on the cockpit, in the + task browser, and inside the browser's detail popup — opens a picker over + every registered document with the task's existing links ticked, and ⏎ links + or unlinks the highlighted one without leaving the TUI. Registering and + removing documents remains `voro doc add`/`remove`. - Multi-repo projects: a project now owns one or more **repos**, and a task may name which one it runs in. Manage them with `voro repo add/list/path/default/ remove`, pick one per task with `voro add --repo NAME` and `voro set --repo diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index 1b140ed..93bf79a 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -165,6 +165,18 @@ pub enum Mode { resolved: Option, sel: usize, }, + /// Toggling a task's document links (DESIGN.md §3/§8): every registered + /// document, the task's own project's first, with ⏎ linking or unlinking the + /// highlighted one in place. Which are linked is read from the App's own + /// per-refresh map rather than carried here, so a toggle's refresh is the + /// only thing the list needs to stay current. `back` is the detail popup's + /// scroll when the picker was opened from it, so closing returns there. + DocPicker { + task_id: i64, + docs: Vec, + 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. @@ -913,6 +925,12 @@ impl App { resolved, sel, } => self.key_agent_picker(key, task_id, agents, resolved, sel), + Mode::DocPicker { + task_id, + docs, + sel, + back, + } => self.key_doc_picker(key, task_id, docs, sel, back), Mode::ReviewActionPicker { project_id, options, @@ -1051,6 +1069,11 @@ impl App { self.toggle_deep(id); } } + KeyCode::Char('c') => { + if let Some(id) = self.selected_task_id() { + self.open_doc_picker(id, None); + } + } KeyCode::Char('o') => self.open_selected_in_viewer(), KeyCode::Char('g') => self.open_selected_pr(), KeyCode::Char('a') => self.jump_into_session(), @@ -1635,6 +1658,97 @@ impl App { }; } + /// Open the document picker on a task (DESIGN.md §8): every registered + /// document, since a task in any project may cite any plan (§3), with the + /// task's own project's listed first — the ones a triage is most likely to + /// reach for. Read fresh from the store rather than from the refresh cache, + /// which only holds documents something already links to. Returns whether it + /// opened, so a caller with a screen to restore knows to give way. + fn open_doc_picker(&mut self, task_id: i64, back: Option) -> bool { + let Ok(task) = self.store.task(task_id) else { + return false; + }; + let mut docs = match self.store.all_docs() { + Ok(docs) => docs, + Err(e) => { + self.status = Some(e.to_string()); + return false; + } + }; + if docs.is_empty() { + self.status = + Some("no documents registered — add one with voro doc add ".into()); + return false; + } + docs.sort_by_key(|doc| (doc.project_id != task.project_id, doc.id)); + self.mode = Mode::DocPicker { + task_id, + docs, + sel: 0, + back, + }; + true + } + + /// Drive the document picker: ⏎ links or unlinks the highlighted document + /// through the same `voro-core` calls `doc link`/`doc unlink` make, and the + /// picker stays open on the refreshed list so several can be toggled in one + /// visit. Esc returns to the detail popup it was opened from, if any. + fn key_doc_picker( + &mut self, + key: KeyEvent, + task_id: i64, + docs: Vec, + mut sel: usize, + back: Option, + ) { + match key.code { + KeyCode::Esc => { + if let Some(scroll) = back { + self.mode = Mode::Detail { task_id, scroll }; + } + return; + } + KeyCode::Char('j') | KeyCode::Down => { + sel = (sel + 1).min(docs.len().saturating_sub(1)); + } + KeyCode::Char('k') | KeyCode::Up => sel = sel.saturating_sub(1), + KeyCode::Enter => self.toggle_doc_link(task_id, &docs[sel]), + _ => {} + } + self.mode = Mode::DocPicker { + task_id, + docs, + sel, + back, + }; + } + + /// Link or unlink one document, whichever the current state calls for, and + /// refresh so the detail panes behind the picker show the new list. + fn toggle_doc_link(&mut self, task_id: i64, doc: &voro_core::Doc) { + let linked = self.doc_linked(task_id, doc.id); + let result = if linked { + self.store.unlink_doc(task_id, doc.id) + } else { + self.store.link_doc(task_id, doc.id) + } + .and_then(|_| self.refresh()); + if self.report(result).is_some() { + let verb = if linked { "unlinked" } else { "linked" }; + self.status = Some(format!("{verb} {} on task {task_id}", doc.label())); + } + } + + /// Whether a task cites a document, read from the per-refresh link map the + /// detail panes render — so the picker's marks and those lines can never + /// disagree. + pub fn doc_linked(&self, task_id: i64, doc_id: i64) -> bool { + self.docs + .get(&task_id) + .is_some_and(|docs| docs.iter().any(|d| d.id == doc_id)) + } + /// 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 @@ -2278,6 +2392,13 @@ impl App { } } KeyCode::Char('!') => self.toggle_deep(task_id), + // The picker takes over the screen, so hand it this popup's scroll + // to restore; when nothing opens it, fall through and stay put. + KeyCode::Char('c') => { + if self.open_doc_picker(task_id, Some(scroll)) { + return; + } + } // The popup only opens on the selected task, so the selection-based // helper pages the right log. KeyCode::Char('l') => self.view_session_log(), @@ -4095,4 +4216,170 @@ mod tests { app.refresh().unwrap(); assert!(!app.docs.contains_key(&task_id)); } + + /// `c` opens the picker over every registered document and ⏎ toggles the + /// highlighted one, so a link and its removal both happen without leaving + /// the TUI (DESIGN.md §8). + #[test] + fn doc_picker_toggles_the_selected_task_s_links_in_place() { + let mut app = app_with(&[TaskState::Ready]); + let task_id = app.all[0].task.id; + let project_id = app.projects[0].id; + let plan = app + .store + .create_doc(project_id, None, "docs/plan.md", Some("The Plan")) + .unwrap(); + app.store + .create_doc(project_id, None, "docs/rfc.md", None) + .unwrap(); + app.refresh().unwrap(); + + key(&mut app, KeyCode::Char('c')); + let docs = match &app.mode { + Mode::DocPicker { + task_id: id, + docs, + sel, + back, + } => { + assert_eq!(*id, task_id); + assert_eq!(*sel, 0); + assert_eq!(*back, None); + docs.clone() + } + _ => panic!("c should open the document picker"), + }; + assert_eq!(docs.len(), 2); + assert!(!app.doc_linked(task_id, plan.id)); + + // ⏎ links the highlighted document and the picker stays open on it, + // now marked, so a second ⏎ takes the link away again. + key(&mut app, KeyCode::Enter); + assert!(app.doc_linked(task_id, plan.id)); + assert!(matches!(app.mode, Mode::DocPicker { sel: 0, .. })); + assert_eq!( + app.store.docs_for_task(task_id).unwrap(), + vec![plan.clone()] + ); + + key(&mut app, KeyCode::Enter); + assert!(!app.doc_linked(task_id, plan.id)); + assert!(app.store.docs_for_task(task_id).unwrap().is_empty()); + + // Esc from a picker opened off the cockpit lands back on the cockpit. + key(&mut app, KeyCode::Esc); + assert!(matches!(app.mode, Mode::Normal)); + } + + /// The case the picker exists for: citing a plan while triaging a proposal. + /// Proposals ride the queue folded into a per-project digest (DESIGN.md §7) + /// which names no task of its own, so `c` reaches one only once the digest + /// is expanded and the cursor has moved onto the proposal beneath it — and + /// on the digest row itself the key correctly does nothing. + #[test] + fn doc_picker_reaches_a_proposal_under_an_expanded_digest() { + let mut app = app_with(&[TaskState::Proposed]); + let task_id = app.all[0].task.id; + let project_id = app.projects[0].id; + let plan = app + .store + .create_doc(project_id, None, "docs/plan.md", Some("The Plan")) + .unwrap(); + app.refresh().unwrap(); + + assert_eq!(app.selected_task_id(), None, "the digest names no task"); + key(&mut app, KeyCode::Char('c')); + assert!(matches!(app.mode, Mode::Normal)); + + key(&mut app, KeyCode::Enter); + app.move_selection(1); + assert_eq!(app.selected_task_id(), Some(task_id)); + + key(&mut app, KeyCode::Char('c')); + assert!(matches!(app.mode, Mode::DocPicker { .. })); + key(&mut app, KeyCode::Enter); + assert!(app.doc_linked(task_id, plan.id)); + } + + /// A task may cite a plan owned by any project (DESIGN.md §3), so the + /// picker spans them all — with the task's own project's documents first, + /// where a triage most often reaches. + #[test] + fn doc_picker_lists_every_project_s_documents_own_first() { + let mut app = app_with(&[TaskState::Ready]); + let task_id = app.all[0].task.id; + let own = app.projects[0].id; + let other = app.store.create_project("other", "/tmp/other").unwrap().id; + // registered first, so id order alone would put it at the top + let strategy = app + .store + .create_doc(other, None, "docs/strategy.md", Some("Strategy")) + .unwrap(); + let plan = app + .store + .create_doc(own, None, "docs/plan.md", Some("The Plan")) + .unwrap(); + app.refresh().unwrap(); + + key(&mut app, KeyCode::Char('c')); + match &app.mode { + Mode::DocPicker { docs, .. } => { + assert_eq!( + docs.iter().map(|d| d.id).collect::>(), + vec![plan.id, strategy.id] + ); + } + _ => panic!("c should open the document picker"), + } + + // and the out-of-project document links just like an own one + key(&mut app, KeyCode::Char('j')); + key(&mut app, KeyCode::Enter); + assert!(app.doc_linked(task_id, strategy.id)); + } + + /// With nothing registered there is nothing to pick, so the picker says so + /// on the status line — pointing at the CLI verb that registers one, which + /// the TUI deliberately does not — rather than opening empty. + #[test] + fn doc_picker_with_no_documents_reports_instead_of_opening() { + let mut app = app_with(&[TaskState::Ready]); + key(&mut app, KeyCode::Char('c')); + assert!(matches!(app.mode, Mode::Normal)); + assert!( + app.status.as_deref().unwrap_or("").contains("voro doc add"), + "{:?}", + app.status + ); + } + + /// Opened from the task browser's detail popup, the picker returns to it on + /// esc with its scroll intact — the reading position survives the detour. + #[test] + fn doc_picker_opened_from_the_detail_popup_returns_to_it() { + let mut app = app_with(&[TaskState::Proposed]); + let task_id = app.all[0].task.id; + let project_id = app.projects[0].id; + app.store + .create_doc(project_id, None, "docs/plan.md", None) + .unwrap(); + app.refresh().unwrap(); + + key(&mut app, KeyCode::Char('2')); + key(&mut app, KeyCode::Enter); + key(&mut app, KeyCode::Char('j')); + assert!(matches!(app.mode, Mode::Detail { scroll: 1, .. })); + + key(&mut app, KeyCode::Char('c')); + assert!(matches!(app.mode, Mode::DocPicker { back: Some(1), .. })); + key(&mut app, KeyCode::Enter); + key(&mut app, KeyCode::Esc); + assert!(matches!( + app.mode, + Mode::Detail { + task_id: id, + scroll: 1 + } if id == task_id + )); + } } diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index 60df4b2..c5ba4f4 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -244,7 +244,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { .wrap(Wrap { trim: false }) .scroll((*scroll, 0)) .block(Block::default().borders(Borders::ALL).title(format!( - "#{task_id} — ⏎ state · 0-3 priority · ! deep · x score · h history · j/k scroll · esc close" + "#{task_id} — ⏎ state · 0-3 priority · ! deep · c docs · x score · h history · j/k scroll · esc close" ))); frame.render_widget(para, area); } @@ -274,6 +274,27 @@ fn draw_mode(frame: &mut Frame, app: &App) { .highlight_style(SELECTED); frame.render_stateful_widget(list, area, &mut state); } + Mode::DocPicker { + task_id, docs, sel, .. + } => { + let items: Vec = docs + .iter() + .map(|doc| ListItem::new(doc_picker_row(app, *task_id, doc))) + .collect(); + let height = items.len() as u16 + 2; + // Resolved locations are absolute paths, so this picker takes what + // the terminal will give rather than the fixed width the short-row + // pickers use. + let width = frame.area().width.saturating_sub(4).max(44); + let area = popup_area(frame, width, height.max(3)); + let mut state = ListState::default().with_selected(Some(*sel)); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(format!( + "Documents for #{task_id} — ⏎ link/unlink, esc close" + ))) + .highlight_style(SELECTED); + frame.render_stateful_widget(list, area, &mut state); + } Mode::ReviewActionPicker { options, current, @@ -658,6 +679,38 @@ fn doc_lines(app: &App, task_id: i64) -> Vec> { .collect() } +/// One row of the document picker (DESIGN.md §8): a tick for the documents the +/// task already cites, then the same title-and-location pair the detail panes +/// show, so a link made here reads back identically. A document owned by +/// another project carries that project's name, since the list spans them all +/// and two plans can share a filename. +fn doc_picker_row(app: &App, task_id: i64, doc: &voro_core::Doc) -> Line<'static> { + let linked = app.doc_linked(task_id, doc.id); + let owner = app + .all + .iter() + .find(|row| row.task.id == task_id) + .filter(|row| row.task.project_id != doc.project_id) + .and_then(|_| app.projects.iter().find(|p| p.id == doc.project_id)) + .map(|p| format!("[{}] ", p.name)) + .unwrap_or_default(); + let location = app + .doc_locations + .get(&doc.id) + .cloned() + .unwrap_or_else(|| doc.location.clone()); + let text = match &doc.title { + Some(title) => format!("{owner}{title} — {location}"), + None => format!("{owner}{location}"), + }; + let (mark, style) = if linked { + ("✓ ", Style::new().fg(Color::Magenta)) + } else { + (" ", Style::new().dim()) + }; + Line::from(vec![Span::styled(mark, style), Span::styled(text, style)]) +} + /// A review row's next action rendered as a browser suffix (DESIGN.md §3). The /// browser shows state in its own column, so only `review` — whose verb reads /// the tracked PR, not the state alone — earns the suffix. @@ -1460,6 +1513,7 @@ fn key_hints(app: &App) -> Vec<(&'static str, &'static str)> { pairs.push(("s", "state")); if app.selected_task_id().is_some() { pairs.push(("!", "deep")); + pairs.push(("c", "docs")); pairs.push(("x", "score")); pairs.push(("h", "history")); } @@ -1487,6 +1541,7 @@ fn key_hints(app: &App) -> Vec<(&'static str, &'static str)> { } pairs.push(("s", "state")); pairs.push(("!", "deep")); + pairs.push(("c", "docs")); pairs.push(("n", "new")); pairs.push(("N", "plan")); pairs.push(("e", "edit")); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e7d3363..7f8d0a0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -285,7 +285,7 @@ Naming the id literally is what makes the return path survive the launch style t `done`'s optional `--summary` (or `--summary-file`, for a multi-line one) is the agent's own account of what it did: it rides the `running → review` transition and is recorded as a `summary` event on the append-only log — not written into the task body, which stays the human's brief — so the review queue and detail view open on it rather than a bare state change, and `pr` opens the pull request straight from it (below), which is why it is written to read as a PR description. It stays optional throughout — a planning or task-generation task produces no code and no summary — so `done` *warns* rather than fails when a task reaches `review` without a branch or summary. That warning only reaches the caller's stdout — the agent's own log — so the durable surface is an **incomplete-report flag**: a `review` task carrying *exactly one* of a branch and a summary is rendered with an `[incomplete report]` marker in the queue, task browser, detail pane, and `show`/`list`, read fresh from task and event state, never stored. The XOR is deliberate — *neither* half is a legitimate no-artifact task (planning, triage), *both* is a complete report, and one without the other is a done report a dispatched session left unfinished. The flag names the report rather than promising a `pr` failure because the anomaly holds on *every* review medium (below): the summary is still what the review queue and reject-with-feedback read, and the branch still ties the task to its work, so it applies unchanged across media. Surfacing it is what makes the dispatch guarantee hold: every dispatched session ends either with a complete report or with a *visible* anomaly the operator can act on — the `stalled` state for a session that died without reporting (below), or this marker for one that reported only half. Because the branch is registered early (below), the flag most often catches a missing `--summary`. The summary is not write-once: `voro set --summary TEXT` (or `--summary-file PATH`) appends a fresh `summary` event, and because every reader takes the *newest*, the new account supersedes the old on the next read while the log keeps both — amending a thin summary before `pr`, or supplying the missing half of an `[incomplete report]` in place instead of churning through `reject` → re-`done`. It is allowed on a `running` task (a resumed agent recording its account before `done`) or a `review` one (fixing the report after), and it only ever writes the event, never `tasks.state`. -**Linked documents** (§3) ride the same preamble mechanism as branch names, and for the same reason: the dispatcher already owns the prompt file, so the plan a task derives from can be handed over rather than left to be rediscovered from hints in the body. A task carrying document links renders an extra block naming each one at its *resolved* location, ahead of the body separator so it is read before the task itself — absolute for a path, because a linked document may live in another project's checkout entirely and a location relative to the session's working directory would point at nothing. A task with no links renders no block at all, so an unlinked dispatch's prompt is byte-for-byte what it was before documents existed. Voro neither reads nor parses the document: it names it, exactly as it names a branch, and what the agent does with it is the agent's business. Registering and linking documents is a CLI affair (`doc add`/`link`/`unlink`/`remove`, plus `--doc` on `add` and `set`, where it replaces the whole list as `--blocked-by` does); the TUI renders a task's documents in its detail panes and edits none of them, a deliberate scope cut that keeps the cockpit about attention rather than librarianship. +**Linked documents** (§3) ride the same preamble mechanism as branch names, and for the same reason: the dispatcher already owns the prompt file, so the plan a task derives from can be handed over rather than left to be rediscovered from hints in the body. A task carrying document links renders an extra block naming each one at its *resolved* location, ahead of the body separator so it is read before the task itself — absolute for a path, because a linked document may live in another project's checkout entirely and a location relative to the session's working directory would point at nothing. A task with no links renders no block at all, so an unlinked dispatch's prompt is byte-for-byte what it was before documents existed. Voro neither reads nor parses the document: it names it, exactly as it names a branch, and what the agent does with it is the agent's business. Registering a document stays a CLI affair (`doc add`/`remove`, plus `--doc` on `add` and `set`, where it replaces the whole list as `--blocked-by` does), but *linking* one does not: `c` on a selected task — on the cockpit, in the task browser, and inside the browser's detail popup — opens a picker over every registered document with the ones the task already cites ticked, and ⏎ links or unlinks the highlighted one in place through the same store calls `doc link`/`doc unlink` make, leaving the picker open so several can be toggled in one visit. Linking earns the key that registration does not because the moment a link most wants making is while triaging a proposal in the queue, which is exactly where the operator already is, whereas registration is a rarer and wordier act — a location, a title, sometimes a repo — with no such pull. The picker spans every project's documents rather than the task's own, since a task in any project may cite any plan (§3), with the owning project's name on the ones that are not the task's and the task's own listed first. That picker is the whole of the TUI's librarianship: there is no documents screen, and a document's own row — its title, its location, the tasks it backs — remains `doc list`/`doc show`, which keeps the cockpit about attention. **Branch names** flow through dispatch in both directions, and Voro runs no git in either — it only passes a name in and records one back. A task carries an optional `branch` (schema §5): the *intended* name a human sets with `voro set --branch`, which is the mechanism for attaching a task to an existing branch as much as for naming a fresh one. When set, dispatch renders it into the prompt preamble — telling the agent to create or check out that branch itself before working, since the agent knows the checkout's state better than the dispatcher and Voro deliberately never touches the working tree. Either way — whether a human named the branch or the agent chooses its own — the preamble tells the agent to register that branch with `voro set --branch NAME` the moment it creates or checks it out, so Voro records the real branch while the task is still `running` (letting reconcile, attach, `voro pr`, and the UI reflect it, and capturing it even if the agent never reaches a clean `done`) rather than only learning it at completion. The reverse direction is the *reported* name: `voro done --branch NAME` (and, belt-and-braces, the `SessionEnd` hook in [`agent-integration.md`](agent-integration.md)) records the branch the work actually landed on, overwriting any intended name — and re-confirms the early-registered name for the assigned case. The intended name is a suggestion the agent may follow or override; the reported name is the source of truth. Storing it on the task rather than the session means it survives redispatch and reads naturally beside `pr_url`, so a task correlates with its PR and its branch at a glance; Voro never reads the checkout's HEAD to infer it, consistent with the task-state-versus-session-state boundary above.