Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 95 additions & 49 deletions crates/voro/src/app.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
}
Expand All @@ -2699,30 +2729,46 @@ 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));
assert!(app.pending_plan.is_some() || app.status.is_some());
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"
);
}

Expand Down
4 changes: 2 additions & 2 deletions crates/voro/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <task-id> ready → running
ask <task-id> --question TEXT running → needs-input
resume <task-id> needs-input → running, once you have answered
Expand Down Expand Up @@ -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
));
};
Expand Down
16 changes: 14 additions & 2 deletions crates/voro/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 "⏎ <verb>"; split the glyph from the verb so the
// glyph renders as the bold key and the verb as the dim label.
Expand All @@ -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"));
Expand Down Expand Up @@ -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"));
Expand Down
2 changes: 1 addition & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> 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 <id> --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.

Expand Down
Loading