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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ dimmed. The keys also work without opening the menu.
|---|---|
| anywhere | `Tab` cycle tree · document · notes; `E` send; `t` tree; `r` reload; `o` open the file in `$EDITOR`; `q` quit |
| reply review | `S` send and close |
| gated review | `A` approve; `E` send and finish; `q` dismiss |
| document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `0` `$`, `Enter` to finish; `i` move the cursor with those keys first, then `v` to select from there; `I`/`D` attach or remove an image on the block's note |
| toolbar | `a` looks good · `c` comment · `d` delete · `Esc` |
| notes | `j`/`k`; `e` edit; `x` remove; `I` attach an image, `D` remove the last; click a bubble |
Expand All @@ -106,6 +107,39 @@ dimmed. The keys also work without opening the menu.

These are the defaults; every key can be rebound under `[keys]` (see [Keys](#keys)).

### Approval gate

When a script or an agent must know whether you signed off, add `--gate`. The review gains
an **Approve** action (`A`, and an `Approve (A)` button on the header's edge) beside Send and
quit, and ends with one decision: Approve is `approved`, a successful Send is `annotated`, and
quitting is `dismissed`. Notes you have not sent yet ride along with an approval
(`Approve with 2 notes (A)`). Without `--gate` nothing changes.

```sh
plannotator-tui docs/plan.md --gate # prints the outcome when you finish
plannotator-tui docs/plan.md --gate --json --require-approval --result-file /tmp/plan-decision.json
```

The output is the one `plannotator annotate --gate` prints. Plain `--gate` prints `The user
approved.`, the approved-with-notes message (the notes plus the file they are about), the
feedback, or nothing for a dismissal. `--json` prints one line instead,
`{"decision":"approved|annotated|dismissed","feedback":"…"}`; `feedback` is absent from a
bare approval and a dismissal, and the line also carries `annotationCount` and the reviewed
`target`. `--json` without `--gate` decides between `annotated` and `dismissed` only.

For a gate a script can trust, add either or both strict flags (they need `--gate --json`):

- `--require-approval` exits 0 only on approval and 1 on `annotated` or `dismissed`.
- `--result-file <path>` also writes `{"decision":…,"feedback":…}` to `<path>`, atomically,
and never over an existing file: the folder must exist and the file must not. A relative
path starts at the current directory.

Under a strict flag a failure of the gate itself (a bad flag, a missing file, a result file
that cannot be written) exits 2, never 1. The review draws on stdout, so a gated review
refuses to start when stdout is not a terminal: read the decision from `--result-file`
rather than capturing stdout. Each decision is also added to the feedback archive as
`approved`, `approved-with-notes`, `feedback` or `dismissed`.

`o` hands the terminal to `$VISUAL` (or `$EDITOR`, which may carry flags such as `code -w`)
on the open file, at the current line for vim, nvim, nano, helix, emacs, micro and kak. When
the editor exits the viewer comes back and, if the file changed, reloads it as `r` does so
Expand Down Expand Up @@ -139,6 +173,12 @@ if you install the add-on: `herdr plugin install plannotator/herdr-annotate/link
placement = "overlay" # overlay (full tab, default) | split | popup
```

`plannotator-tui herdr open plan.md --gate [--result-file <path>]` opens a gated review. Approve
sends the agent `The user approved.` (or the approved-with-notes message), Send sends the
numbered feedback as usual, and quitting sends nothing; the window closes on any of the three.
With `--result-file` the pane also writes the decision JSON there. The command returns as soon
as the pane opens, so a script waits for the file.

`plannotator-tui herdr last --newest` opens the agent's newest reply without asking which
one; without the flag the picker comes first, as it always has.

Expand Down Expand Up @@ -237,6 +277,7 @@ screen show your keys. `plannotator-tui config` prints every action with its key
| `quit` `interrupt` | `q` `ctrl+c` | quit |
| `cycle-focus` | `Tab` | tree · document · notes |
| `send` `send-and-close` | `E` `S` | send; send and close a reply review |
| `approve` | `A` | approve a gated review (`--gate`) |
| `toggle-tree` `reload` `pick-message` | `t` `r` `p` | show/hide the tree; reload; choose another reply |
| `open-in-editor` | `o` | edit the open file in `$VISUAL` or `$EDITOR`, then reload it |
| `quick-open` | `ctrl+p` | find a file by name in a folder review |
Expand Down
1 change: 1 addition & 0 deletions crates/plannotator-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ cargo build --release
| selection toolbar | `a` 👍 looks good · `c` 💬 comment (opens a box at the selection) · `d` ✗ delete · `Esc` clears |
| rail | `j`/`k` move · `e` / `Enter` edit body · `x` remove · `I` attach a local image by path · `D` remove the last image · click a bubble to focus it |
| reply review | `S` send feedback and quit; stays open if the agent is at a dialog |
| gated review (`--gate`) | `A` approve, with any unsent notes · `E` send and finish · `q` dismiss; the decision is printed (`--json` for JSON) and, with `--result-file`, written there |
| file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) |
| tree | `j`/`k` move · `Enter` open · `.` show/hide hidden (dot-prefixed) entries · `E` send new feedback across files, including collapsed folders · counts show active notes per file |
| quick open (folder) | `Ctrl+P` find a Markdown file by name anywhere in the folder · type to filter · ↑/↓ or wheel select · `Enter` or click open · `Esc` cancel |
Expand Down
92 changes: 92 additions & 0 deletions crates/plannotator-tui/src/app/approve.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//! Gate mode in the app: the Approve action, and the decision a gated review ends with.
//! Send and quit keep their meaning; in a gated review each also ends it (decision 17).

use anyhow::Result;
use plannotator_tui_schema::Provenance;

use super::App;
use super::feedback::{Feedback, SendScope};
use super::send::SendState;
use crate::delivery::DeliveryError;
use crate::gate::{self, Decision, Gate};
use crate::keys::Action;

#[cfg(test)]
mod tests;

impl App {
/// Approve and end the review. Notes not yet sent ride along as approval notes, which
/// Plannotator calls approving with notes. An agent receives the approval message; a
/// refused delivery keeps the review open, as a refused send does.
pub(super) fn approve(&mut self) -> Result<()> {
if self.gate != Gate::Approve {
return Ok(());
}
let scope = if self.is_file_review() { SendScope::Pending } else { SendScope::All };
let mut feedback = self.prepare_feedback(scope)?;
if self.tree.is_some() {
self.folder_counts = std::mem::take(&mut feedback.counts);
}
let target = self.delivery.describe();
let retry =
self.keys.labelled(Action::Approve, "retry").unwrap_or_else(|| "click to retry".to_owned());
match self.delivery.deliver(&gate::approval_message(&feedback.text, &self.context_line())) {
Ok(()) => {
let decision = Decision::Approved { feedback: feedback.text.clone(), count: feedback.count };
self.archive_submission(&mut feedback, decision.archive_decision());
// The notes reached the agent with the approval, so they count as sent. The
// review is ending; a record that cannot say so only re-offers them next time.
let _ = self.remember_delivery(&mut feedback, &target);
self.derive_send_state();
self.decision = Some(decision);
self.quit = true;
}
Err(DeliveryError::Blocked(msg)) => {
self.status = Some(format!("{target} is at a dialog · {retry}"));
self.send_state = SendState::Blocked(msg);
}
Err(DeliveryError::Unavailable(msg)) => {
self.status = Some(format!("no agent to send the approval to ({msg}) · {retry}"));
}
Err(DeliveryError::Failed(err)) => {
self.status = Some(format!("approve failed: {err:#} · {retry}"));
}
}
Ok(())
}

/// `Approve (A)`, or `Approve with 2 notes (A)` when unsent notes would ride along.
pub(super) fn approve_label(&self) -> String {
let notes = match self.send_count() {
0 => String::new(),
1 => " with 1 note".to_owned(),
n => format!(" with {n} notes"),
};
let key = match self.keys.hint(Action::Approve) {
key if key.is_empty() => String::new(),
key => format!(" ({key})"),
};
format!("Approve{notes}{key}")
}

/// The `File:` or `Folder:` line an approval with notes names its target by.
fn context_line(&self) -> String {
match (&self.tree, &self.open.source.provenance) {
(Some(tree), _) => gate::context_line(tree.root(), true),
(None, Provenance::File { path }) => gate::context_line(path, false),
_ => String::new(),
}
}

/// How a gated review ended, for whoever ran it; `None` for an ordinary review.
/// Quitting without approving or sending is a dismissal, archived as one.
pub(crate) fn take_decision(&mut self) -> Option<Decision> {
if self.gate == Gate::Off {
return None;
}
Some(self.decision.take().unwrap_or_else(|| {
self.archive_submission(&mut Feedback::default(), Decision::Dismissed.archive_decision());
Decision::Dismissed
}))
}
}
172 changes: 172 additions & 0 deletions crates/plannotator-tui/src/app/approve/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#![allow(clippy::expect_used, clippy::indexing_slicing, reason = "tests assert by panicking")]

use plannotator_tui_schema::Kind;

use crate::app::Mode;
use crate::app::review_test_support::{Outcome, click, draw, file_app, folder_app, press};
use crate::gate::{Decision, Gate};

fn archived(app: &crate::app::App) -> Vec<serde_json::Value> {
let index = app.data_dir.join("feedback").join(&app.project).join("index.jsonl");
std::fs::read_to_string(index)
.unwrap_or_default()
.lines()
.map(|line| serde_json::from_str(line).expect("json line"))
.collect()
}

#[test]
fn without_a_gate_nothing_changes() {
let (root, mut app, delivery) = file_app("ungated");
app.add_quote_annotation("one", Kind::Comment, "note".into()).expect("note");
let screen = draw(&mut app, 100, 12);
assert!(!screen.contains("Approve"), "{screen}");
assert!(app.geometry.approve_button.is_none());
assert!(!screen.lines().last().unwrap_or_default().contains("approve"));

press(&mut app, 'A');
assert!(delivery.calls.borrow().is_empty(), "A means nothing outside a gate");
assert!(!app.quit);
press(&mut app, 'E');
assert_eq!(delivery.calls.borrow().len(), 1);
assert!(!app.quit, "an ordinary send leaves the review open");
assert_eq!(app.take_decision(), None);
assert!(archived(&app).iter().all(|record| record["decision"] == "feedback"));
let _ = std::fs::remove_dir_all(root);
}

#[test]
fn a_bare_approval_tells_the_agent_and_ends_the_review() {
let (root, mut app, delivery) = file_app("bare");
app.gate = Gate::Approve;
press(&mut app, 'A');
assert_eq!(*delivery.calls.borrow(), ["The user approved."]);
assert!(app.quit);
assert_eq!(app.take_decision(), Some(Decision::Approved { feedback: String::new(), count: 0 }));
let records = archived(&app);
assert_eq!(records.len(), 1);
assert_eq!(records[0]["decision"], "approved");
assert!(records[0].get("recordFile").is_none(), "a bare approval is a decision-only line");
let _ = std::fs::remove_dir_all(root);
}

#[test]
fn unsent_notes_ride_along_as_an_approval_with_notes() {
let (root, mut app, delivery) = file_app("notes");
app.gate = Gate::Approve;
app.add_quote_annotation("two", Kind::Comment, "keep it short".into()).expect("note");
let screen = draw(&mut app, 100, 12);
assert!(screen.lines().next().unwrap_or_default().contains("Approve with 1 note (A)"), "{screen}");

press(&mut app, 'A');
let message = delivery.calls.borrow()[0].clone();
let path = root.join("docs/a.md");
assert!(message.starts_with("# Approved with Notes\n\nThe artifact is approved."), "{message}");
assert!(message.contains(&format!("File: {}\n\n# Annotations on a.md", path.display())), "{message}");
assert!(message.contains("> keep it short"), "{message}");
assert_eq!(app.send_count(), 0, "the notes went with the approval");
let decision = app.take_decision();
assert!(
matches!(&decision, Some(Decision::Approved { feedback, count: 1 })
if feedback.starts_with("# Annotations on a.md")),
"{decision:?}"
);
assert_eq!(archived(&app)[0]["decision"], "approved-with-notes");
let _ = std::fs::remove_dir_all(root);
}

#[test]
fn a_gated_send_ends_the_review_as_annotated() {
let (root, mut app, delivery) = file_app("send");
app.gate = Gate::Approve;
app.add_quote_annotation("one", Kind::Comment, "fix".into()).expect("note");
press(&mut app, 'E');
assert!(app.quit);
let sent = delivery.calls.borrow()[0].clone();
assert!(sent.starts_with("# Annotations on a.md"), "the numbered feedback, as an ordinary send");
assert_eq!(app.take_decision(), Some(Decision::Annotated { feedback: sent, count: 1 }));
assert_eq!(archived(&app)[0]["decision"], "feedback");
let _ = std::fs::remove_dir_all(root);
}

#[test]
fn quitting_a_gated_review_dismisses_it() {
let (root, mut app, delivery) = file_app("quit");
app.gate = Gate::Approve;
press(&mut app, 'q');
assert!(app.quit);
assert_eq!(app.take_decision(), Some(Decision::Dismissed));
assert!(delivery.calls.borrow().is_empty(), "a dismissal tells the agent nothing");
assert_eq!(archived(&app)[0]["decision"], "dismissed");

// Unsent notes ask first; declining the send still dismisses.
let (other, mut app, delivery) = file_app("quit-notes");
app.gate = Gate::Approve;
app.add_quote_annotation("one", Kind::Comment, "fix".into()).expect("note");
press(&mut app, 'q');
assert_eq!(app.mode, Mode::ConfirmQuit);
press(&mut app, 'n');
assert!(app.quit);
assert_eq!(app.take_decision(), Some(Decision::Dismissed));
assert!(delivery.calls.borrow().is_empty());
let _ = std::fs::remove_dir_all(root);
let _ = std::fs::remove_dir_all(other);
}

#[test]
fn a_refused_approval_keeps_the_review_open() {
let (root, mut app, delivery) = file_app("blocked");
app.gate = Gate::Approve;
delivery.outcome.set(Outcome::Blocked);
press(&mut app, 'A');
assert!(!app.quit);
assert!(app.status.as_deref().is_some_and(|s| s.contains("at a dialog · A retry")), "{:?}", app.status);
assert!(archived(&app).is_empty(), "nothing was decided");
delivery.outcome.set(Outcome::Success);
press(&mut app, 'A');
assert!(app.quit);
let _ = std::fs::remove_dir_all(root);
}

#[test]
fn the_approve_button_sits_on_the_edge_and_approves_when_clicked() {
let (root, mut app, delivery) = file_app("button");
app.gate = Gate::Approve;
let screen = draw(&mut app, 100, 12);
let header = screen.lines().next().unwrap_or_default();
assert!(header.trim_end().ends_with("Approve (A)"), "{header}");
let footer = screen.lines().last().unwrap_or_default();
assert!(footer.contains("A approve"), "{footer}");
let rect = app.geometry.approve_button.expect("drawn");
assert_eq!(rect.right(), 100);
click(&mut app, rect);
assert_eq!(*delivery.calls.borrow(), ["The user approved."]);
assert!(app.quit);
let _ = std::fs::remove_dir_all(root);
}

#[test]
fn json_alone_decides_without_an_approve() {
let (root, mut app, delivery) = file_app("decision");
app.gate = Gate::Decision;
let screen = draw(&mut app, 100, 12);
assert!(!screen.contains("Approve"), "{screen}");
press(&mut app, 'A');
assert!(delivery.calls.borrow().is_empty() && !app.quit);
press(&mut app, 'q');
assert_eq!(app.take_decision(), Some(Decision::Dismissed));
let _ = std::fs::remove_dir_all(root);
}

#[test]
fn a_folder_approval_names_the_folder() {
let (root, mut app, delivery) = folder_app("folder");
app.gate = Gate::Approve;
app.add_quote_annotation("one", Kind::Comment, "fix".into()).expect("note");
app.refresh_review_counts();
press(&mut app, 'A');
let message = delivery.calls.borrow()[0].clone();
assert!(message.contains(&format!("Folder: {}\n\n", root.join("docs").display())), "{message}");
assert!(matches!(app.take_decision(), Some(Decision::Approved { count: 1, .. })));
let _ = std::fs::remove_dir_all(root);
}
4 changes: 4 additions & 0 deletions crates/plannotator-tui/src/app/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
use unicode_width::UnicodeWidthStr;

use super::{App, Focus, GUTTER, Geometry, Mode, TOOLBAR, glyph, label};
use crate::gate::Gate;
use crate::keys::Action;
use crate::theme::palette;
use crate::wrap::wrap_line;
Expand Down Expand Up @@ -487,6 +488,7 @@ impl App {
let send_and_close =
if self.is_file_review() { None } else { key(Action::SendAndClose, "send+quit") };
let quit = [send_and_close, key(Action::Quit, "quit")];
let approve = (self.gate == Gate::Approve).then(|| key(Action::Approve, "approve")).flatten();
// The image keys act on the current note; removing is offered only when it has one.
let note = self.current_note();
let has_images = note.as_deref().is_some_and(|id| self.open.store.has_images(id));
Expand All @@ -503,6 +505,7 @@ impl App {
key(Action::Confirm, "open"),
key(Action::ToggleHidden, "hidden"),
key(Action::Send, "send"),
approve,
key(Action::ToggleTree, "hide"),
key(Action::Quit, "quit"),
],
Expand Down Expand Up @@ -533,6 +536,7 @@ impl App {
note.is_some().then(|| key(Action::AttachImage, "image")).flatten(),
remove_image,
key(Action::Send, "send"),
approve,
tab,
]
.into_iter()
Expand Down
Loading
Loading