diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index 1b140ed..7b4d0f1 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -1,4 +1,4 @@ -use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use voro_core::{ Action, ActionRow, AgentsConfig, DepKind, DepRef, DigestRow, Event, PrRef, Priority, Project, Queue, QueueRow, ReviewAction, ReviewMedium, RunningRow, ScoreBreakdown, StateCounts, Store, @@ -207,6 +207,15 @@ pub enum CreateFlow { Plan, } +/// Which refine intensity a keypress asks for (DESIGN.md §6): a one-line note +/// feeding a headless rewrite on `r`, or the interactive planning session on +/// `R` — the same lowercase-default, uppercase-variant pairing as `n`/`N`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefineFlow { + Note, + Interactive, +} + /// A request for main() to suspend the terminal and run $EDITOR. #[derive(Debug, Clone, Copy)] pub enum EditorRequest { @@ -992,10 +1001,12 @@ impl App { _ => {} } match key.code { - KeyCode::Char('r') => { + KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { let result = self.refresh(); self.report(result); } + KeyCode::Char('r') => self.refine_selected(RefineFlow::Note), + KeyCode::Char('R') => self.refine_selected(RefineFlow::Interactive), KeyCode::Enter => self.activate_selection(), KeyCode::Char('n') => self.new_task(CreateFlow::Editor), KeyCode::Char('N') => self.new_task(CreateFlow::Plan), @@ -1215,14 +1226,43 @@ impl App { } } - /// Whether a task is still awaiting triage — what gates the triage menu's - /// refine keys. + /// Whether a task is still awaiting triage — what gates the refine keys, + /// on the queue and in the triage menu alike. pub fn is_proposed(&self, task_id: i64) -> bool { self.all .iter() .any(|r| r.task.id == task_id && r.task.state == TaskState::Proposed) } + /// Refine the selected proposal (DESIGN.md §6). Refine is an event on a + /// proposal rather than a verdict on one, so it answers from the queue — + /// where the operator reads the body and notices it is sub-standard — and + /// not only from behind the triage menu, which collects verdicts. A + /// selection that is not a proposal reports why via the status line, the + /// same no-op-with-explanation style as the other action keys. + fn refine_selected(&mut self, flow: RefineFlow) { + let Some(task) = self.selected_task() else { + return; + }; + let (task_id, state) = (task.id, task.state); + if state != TaskState::Proposed { + self.status = Some(format!( + "task is {state} — refine works on a proposal awaiting triage" + )); + return; + } + match flow { + RefineFlow::Note => { + self.mode = Mode::Prompt { + task_id, + kind: PromptKind::RefineNote, + buffer: String::new(), + } + } + RefineFlow::Interactive => self.refine_interactively(task_id), + } + } + /// Note-driven refine (DESIGN.md §6): hand the body, the note, and the /// discovered-from context to a headless agent that rewrites the body in /// place. No transition — the task stays `proposed` and comes back round @@ -2180,22 +2220,6 @@ impl App { sel = (sel + 1).min(actions.len().saturating_sub(1)); } KeyCode::Char('k') | KeyCode::Up => sel = sel.saturating_sub(1), - // The fourth triage outcome (DESIGN.md §6), on the two keys the - // menu advertises for a proposal: `r` collects a one-line note for - // a headless rewrite, `R` opens the interactive conversation. Both - // leave the task `proposed`, so neither is a menu verdict. - KeyCode::Char('r') | KeyCode::Char('R') if self.is_proposed(task_id) => { - if key.code == KeyCode::Char('r') { - self.mode = Mode::Prompt { - task_id, - kind: PromptKind::RefineNote, - buffer: String::new(), - }; - } else { - self.refine_interactively(task_id); - } - return; - } KeyCode::Enter => { let action = actions[sel].clone(); let kind = match action { @@ -2360,6 +2384,10 @@ mod tests { app.on_key(KeyEvent::from(code)); } + fn ctrl_key(app: &mut App, code: KeyCode) { + app.on_key(KeyEvent::new(code, KeyModifiers::CONTROL)); + } + /// A `DispatchCtx` that is never actually used to spawn anything in these /// tests — the transitions they drive (`resume`, reject) only move state. fn dummy_ctx() -> crate::dispatch::DispatchCtx { @@ -2654,38 +2682,40 @@ mod tests { assert_eq!(app.enter_hint(), Some("⏎ act")); } - /// The two keystrokes a proposal's triage menu now takes: Enter folds the - /// project's digest open, Enter on the proposal beneath it opens the menu. - fn open_triage_menu(app: &mut App) -> i64 { + /// Select a queued proposal: the cockpit collapses them into a per-project + /// digest (DESIGN.md §7), so Enter folds it open before a constituent row + /// can be selected. + fn select_proposal(app: &mut App) -> i64 { key(app, KeyCode::Enter); app.move_selection(1); - key(app, KeyCode::Enter); - match &app.mode { - Mode::Transition { task_id, .. } => *task_id, - _ => panic!("expected the triage menu"), - } + app.selected_task_id() + .expect("a folded-open digest should select a proposal") } - /// `r` in the triage menu collects the note the refine agent is briefed - /// with (DESIGN.md §6). The launch itself needs a configured agent, which - /// the dummy context has none of, so what is asserted here is the path: the - /// prompt opens, submitting it reaches the dispatch, and the task stays - /// `proposed` either way — refine is an event, not a verdict. + /// Refine answers from the queue (DESIGN.md §6): `r` over a selected + /// proposal collects the note directly. #[test] - fn refine_key_in_the_triage_menu_collects_a_note_and_moves_no_state() { + fn refine_key_on_the_queue_collects_a_note_without_the_triage_menu() { let mut app = app_with(&[TaskState::Proposed]); - let task_id = open_triage_menu(&mut app); + let task_id = select_proposal(&mut app); key(&mut app, KeyCode::Char('r')); match &app.mode { Mode::Prompt { + task_id: id, kind: PromptKind::RefineNote, buffer, - .. - } => assert!(buffer.is_empty(), "buffer was {buffer:?}"), - _ => panic!("r on a proposal should open the refine-note prompt"), + } => { + assert_eq!(*id, task_id); + assert!(buffer.is_empty(), "buffer was {buffer:?}"); + } + _ => panic!("r on a queued proposal should open the refine-note prompt"), } + // The launch itself needs a configured agent, which the dummy context + // has none of, so what is asserted is the path: submitting the note + // reaches the dispatch and reports, and the task stays `proposed` + // either way — refine is an event, not a verdict. for c in "thin".chars() { key(&mut app, KeyCode::Char(c)); } @@ -2699,13 +2729,13 @@ mod tests { ); } - /// `R` opens the interactive variant — the planning harness pointed at the - /// existing task. The dummy context configures no `plan` verb, so the - /// failure lands on the status line rather than transitioning anything. + /// `R` over a queued proposal reaches the interactive variant. The dummy + /// context configures no `plan` verb, so the failure lands on the status + /// line rather than transitioning anything. #[test] - fn talk_key_in_the_triage_menu_reaches_the_plan_flow() { + fn talk_key_on_the_queue_reaches_the_plan_flow() { let mut app = app_with(&[TaskState::Proposed]); - let task_id = open_triage_menu(&mut app); + let task_id = select_proposal(&mut app); key(&mut app, KeyCode::Char('R')); assert!(matches!(app.mode, Mode::Normal)); @@ -2713,16 +2743,32 @@ mod tests { assert_eq!(app.store.task(task_id).unwrap().state, TaskState::Proposed); } - /// The refine keys belong to the triage menu alone: on any other state the - /// menu keeps its own bindings, so `r` is not swallowed. + /// On anything but a proposal the queue's refine keys are a no-op that says + /// why, the same style as the other action keys — not a silent swallow. #[test] - fn the_refine_keys_are_inert_outside_a_proposal() { + fn the_queue_refine_keys_explain_themselves_on_a_non_proposal() { let mut app = app_with(&[TaskState::Ready]); - key(&mut app, KeyCode::Enter); + key(&mut app, KeyCode::Char('r')); + assert!(matches!(app.mode, Mode::Normal)); assert!( - matches!(app.mode, Mode::Transition { .. }), - "r should not open a refine prompt on a ready task" + app.status.as_deref().is_some_and(|s| s.contains("refine")), + "expected a status line explaining refine, got {:?}", + app.status + ); + } + + /// Refresh keeps a key, one modifier along: `ctrl-r` refreshes and does not + /// fall through to refine, even with a proposal selected. + #[test] + fn ctrl_r_refreshes_rather_than_refining() { + let mut app = app_with(&[TaskState::Proposed]); + select_proposal(&mut app); + + ctrl_key(&mut app, KeyCode::Char('r')); + assert!( + matches!(app.mode, Mode::Normal), + "ctrl-r should not open the refine prompt" ); } diff --git a/crates/voro/src/cli.rs b/crates/voro/src/cli.rs index 70d3ac8..86b2412 100644 --- a/crates/voro/src/cli.rs +++ b/crates/voro/src/cli.rs @@ -186,7 +186,7 @@ transitions and marked ↻ refined for a re-triage of the improved version. The note-less interactive variant is a conversation with the agent, so - it lives in the TUI's triage menu + it lives in the TUI, on `R` over a proposal start ready → running ask --question TEXT running → needs-input resume needs-input → running, once you have answered @@ -2030,7 +2030,7 @@ fn triage_verb(store: &mut Store, args: TriageArgs, ctx: &DispatchCtx) -> Result return Err(format!( "refine needs a note saying what to fix: `voro triage {} refine --note \ \"...\"`. The note-less interactive variant is a conversation with the \ - agent, so it lives in the TUI's triage menu", + agent, so it lives in the TUI, on `R` over a proposal", args.task_id )); }; diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index 60df4b2..511d2f3 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -1439,9 +1439,15 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { /// The contextual per-screen key line (ui-redesign §2): the actions that apply /// on the current screen and selection, as key/label pairs the caller renders -/// key-bold, label-dim. It lists actions, not navigation (`j`/`k` and `r` +/// key-bold, label-dim. It lists actions, not navigation (`j`/`k` and `ctrl-r` /// refresh are omitted); `q` and `tab` are always present. Selection-only -/// actions drop out on the cockpit when nothing is selected. +/// actions drop out on the cockpit when nothing is selected, and the refine +/// keys appear only on a proposal, which is all they act on. +/// Whether the selection is a proposal, which is the only thing refine acts on. +fn selection_is_proposed(app: &App) -> bool { + app.selected_task_id().is_some_and(|id| app.is_proposed(id)) +} + fn key_hints(app: &App) -> Vec<(&'static str, &'static str)> { // `enter_hint` yields "⏎ "; split the glyph from the verb so the // glyph renders as the bold key and the verb as the dim label. @@ -1457,6 +1463,9 @@ fn key_hints(app: &App) -> Vec<(&'static str, &'static str)> { pairs.push(("d", "dispatch")); pairs.push(("D", "agent")); } + if selection_is_proposed(app) { + pairs.push(("r/R", "refine")); + } pairs.push(("s", "state")); if app.selected_task_id().is_some() { pairs.push(("!", "deep")); @@ -1485,6 +1494,9 @@ fn key_hints(app: &App) -> Vec<(&'static str, &'static str)> { if app.selected_can_hand_off() { pairs.push(("w", "wait")); } + if selection_is_proposed(app) { + pairs.push(("r/R", "refine")); + } pairs.push(("s", "state")); pairs.push(("!", "deep")); pairs.push(("n", "new")); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e7d3363..1bc2cc7 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -201,7 +201,7 @@ Three deliberate choices. Any triaged, non-terminal state can be *abandoned* str Triage has a fourth outcome that is deliberately *not* a verdict. A proposal whose body is sub-standard leaves the operator three bad options — accept it as it stands, reject it and lose the work, or pay the manual edit cost — and accepting wins by default, which exports the quality problem downstream to dispatch and review. **Refine** is the fourth: `voro triage refine --note "..."` hands the body, the operator's one-line note, the task's linked documents (§3), and the body and completion summary of the task it was `discovered-from` to a headless agent, whose whole job is to rewrite the body as a dispatchable prompt honouring the note and apply it with `voro set --body-file` — the CLI as the agent's interface, exactly as in dispatch and planning. That seed context is pulled in rather than left to the agent to hunt because it is usually precisely what a sloppy proposal is missing: the plan it was meant to implement, and the work it fell out of. The task stays `proposed` throughout: refine is an *event* on a proposed task, not a state, so nothing about the state machine, the queue, or the score changes, and the improved version comes back round for a real verdict on the next pass. A `refined` event carrying the note marks the row `↻ refined` in the task browser, `list`, and `show` until triage takes the task out of `proposed`, so the operator can see which rows have moved since they last read them. In the queue, where proposals collapse into a per-project digest (§7), the constituent rows carry the marker once the digest is folded open and the digest itself carries the count — `↻ 2 refined` — since a collapsed digest would otherwise hide the very fact that its bodies have improved. The event is recorded at the moment the agent is launched rather than when the body lands — Voro is told, not observing (§8), and the same principle that keeps dispatch agent-agnostic applies here: what the marker promises is that a rewrite was asked for, and the rewritten body is what the operator reads. Refine runs on the *default* agent whatever override the task carries, since an agent override picks who executes a task, not who writes its brief, and it opens no session row: like a planning session it is not a dispatch, it moves no task state, and a run that dies having applied nothing leaves the proposal exactly as it was. -Refine has a second, interactive intensity for the case where a note is not enough. Given no note it opens the planning session of §8 seeded with the task that already exists — the same `plan` verb and the same foreground round-trip as `N`, ending in `set --body-file` rather than `add`, so it edits in place and creates nothing. Because it is a conversation with an agent it is TUI-only for the same reason planning sessions are: the CLI is how an LLM drives Voro, so a note-less `refine` there errors and points at the triage menu, where `r` collects a note and `R` opens the conversation. The two intensities share the note-driven path's guards — both are refused on anything but a `proposed` task, before a prompt is written or a process spawned. +Refine has a second, interactive intensity for the case where a note is not enough. Given no note it opens the planning session of §8 seeded with the task that already exists — the same `plan` verb and the same foreground round-trip as `N`, ending in `set --body-file` rather than `add`, so it edits in place and creates nothing. Because it is a conversation with an agent it is TUI-only for the same reason planning sessions are: the CLI is how an LLM drives Voro, so a note-less `refine` there errors and points at the TUI. Both intensities answer over a selected proposal in the queue — `r` collects a note, `R` opens the conversation — and *only* there, not from behind the triage menu, because that menu collects *verdicts* and refine is deliberately not one (above): a proposal that leaves it is still `proposed`, so putting refine there filed an event under a decision it does not make, and hid it one keypress behind the very menu whose three bad options it exists to escape. The operator notices a sub-standard body while reading it in the queue, which is where the key is. The menu does not keep a second copy: one key in one place is the whole point of moving it, and a duplicate would reintroduce the claim that refine is something the verdict menu does. Refresh moves to `ctrl-r` to free the letter, the manual counterpart to the refresh every mutating action already performs. The two intensities share the note-driven path's guards — both are refused on anything but a `proposed` task, before a prompt is written or a process spawned. The note-driven path is one instance of a general shape: a terse human intent, expanded by an agent into a formal artefact, applied back through an ordinary CLI verb. Expanding a review rejection's one-line feedback the same way is the obvious next instance, so the seed-context-plus-note → agent → apply-via-verb plumbing is factored (`Expansion` in the `voro` crate) rather than written into refine alone.