Skip to content
Open
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ crates/plannotator-tui the app: parse → layout → draw → input. Ta
modules behind a trait so the app has one seam per external system.
herdr/ the Herdr plugin manifest. The launcher it runs is
`plannotator-tui herdr open` (src/herdr/); no shell logic here.
Rex needs no manifest: its launchers and delivery
(`plannotator-tui rex …`, src/rex/) drive the `rex` CLI.
```

Markdown parsing is `pulldown-cmark`; rendering to styled text is `tui-markdown`. We never
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,32 @@ pane. Mirror does not preserve overlay presentation, and Herdr 0.8.2 opens an
overlay in its server's active tab, which can differ from the tab you are viewing.
An explicit `--placement` or `PLANNOTATOR_TUI_PLACEMENT` still takes precedence.

## Inside Rex

Bind keys or actions in Rex to the launchers below. Each opens plannotator-tui in a split beside
the focused pane: the agent's recent replies, its newest reply, the pane's folder, or the pane's
recent output. The header button sends the review to the agent in that pane as its next message:
`Send 3 new ▸ claude in a1d6db (E)`. A pane with no agent copies instead.

Each launcher opens the split and exits:

```sh
plannotator-tui rex last [--newest] # the agent's replies
plannotator-tui rex open [file.md | folder] # a document, or the pane's folder
plannotator-tui rex terminal [--lines N] # the pane's recent output; --print writes it out
plannotator-tui rex find [--lines N] # the newest .md file the pane's output names
```

The pane is `--block <id>`, else `REX_BLOCK`, so an agent that runs a launcher in its own
shell reviews itself; that is how it hands you a plan. `--paste` puts the feedback in the
agent's prompt without pressing Enter and focuses the agent's pane, so you can edit it first;
the header says `claude prompt in a1d6db`. `rex open --gate [--result-file <path>]` adds
Approve for a sign-off, as `herdr open --gate` does. A send waits while Rex reports the agent blocked on
a permission prompt or a question, as it does in Herdr. It also needs the agent to report its
state to Rex (Claude Code and Codex do): until it has, the review copies instead, since Enter
could answer a dialog Rex cannot see, and `--paste` still works. Rex does not report its theme to
programs, so with a light Rex theme set `[ui] theme = "light"`.

## Agent replies

`plannotator-tui last` finds the transcript of the agent that launched your shell and shows a
Expand Down
7 changes: 6 additions & 1 deletion crates/plannotator-tui/src/app/review_test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ use crate::tree::Tree;
pub(super) enum Outcome {
#[default]
Success,
/// Delivered, with something left for the person to do.
Noticed,
Blocked,
Unavailable,
Failed,
Expand All @@ -44,12 +46,15 @@ impl Delivery for RecordingDelivery {
fn deliver(&self, text: &str) -> Result<(), DeliveryError> {
self.calls.borrow_mut().push(text.to_owned());
match self.outcome.get() {
Outcome::Success => Ok(()),
Outcome::Success | Outcome::Noticed => Ok(()),
Outcome::Blocked => Err(DeliveryError::Blocked("at a dialog".into())),
Outcome::Unavailable => Err(DeliveryError::Unavailable("agent gone".into())),
Outcome::Failed => Err(DeliveryError::Failed(anyhow::anyhow!("transport failed"))),
}
}
fn notice(&self) -> Option<String> {
matches!(self.outcome.get(), Outcome::Noticed).then(|| "press Enter there".to_owned())
}
}

pub(super) fn file_app(tag: &str) -> (PathBuf, App, RecordingDelivery) {
Expand Down
3 changes: 3 additions & 0 deletions crates/plannotator-tui/src/app/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ impl App {
String::new()
};
let mut status = format!("{verb} {} annotation(s){across} → {target}", feedback.count);
if let Some(notice) = self.delivery.notice() {
let _ = write!(status, " · {notice}");
}
if !errors.is_empty() {
let _ = write!(
status,
Expand Down
16 changes: 16 additions & 0 deletions crates/plannotator-tui/src/app/send/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,22 @@ fn s_sends_a_reply_review_and_closes() {
std::fs::remove_dir_all(root).expect("cleanup");
}

/// A send that landed with something left to do counts as sent, so it is never pasted twice,
/// and the status says what is left.
#[test]
fn a_send_that_landed_with_a_notice_counts_as_sent_and_shows_the_notice() {
let (root, mut app, delivery) = file_app("noticed");
app.add_quote_annotation("one", Kind::Comment, "A".into()).expect("A");
delivery.outcome.set(Outcome::Noticed);
press(&mut app, 'E');
assert_eq!(app.status.as_deref(), Some("sent 1 annotation(s) → test agent · press Enter there"));
delivery.outcome.set(Outcome::Success);
press(&mut app, 'E');
assert_eq!(delivery.calls.borrow().len(), 1, "nothing is left to send again");
assert_eq!(app.status.as_deref(), Some("nothing new to send"));
std::fs::remove_dir_all(root).expect("cleanup");
}

#[test]
fn s_keeps_the_window_open_while_the_agent_is_at_a_dialog() {
let (root, mut app, delivery) = reply_app("s-blocked");
Expand Down
69 changes: 44 additions & 25 deletions crates/plannotator-tui/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ use crate::doc::Document;
use crate::gate::{self, Decision, GateError, ReviewArgs};
use crate::herdr::context::HerdrEnv;
use crate::layout::DocLayout;
use crate::rex::agent::RexAgent;

const USAGE: &str = "usage:
pub(crate) const USAGE: &str = "usage:
plannotator-tui <file.md | folder> [--gate] [--json] [--require-approval] [--result-file <path>]
plannotator-tui --export <file.md>
plannotator-tui --bench <file.md>
Expand All @@ -40,6 +41,13 @@ const USAGE: &str = "usage:
plannotator-tui herdr last [--placement P] [--deliver-to <pane>] [--newest]
plannotator-tui herdr terminal [--lines N] [--placement P] [--deliver-to <pane>] [--print]
plannotator-tui herdr pane
plannotator-tui rex open [file.md | folder] [--block <id>] [--session <id>] [--paste]
[--gate] [--result-file <path>]
plannotator-tui rex last [--block <id>] [--session <id>] [--newest] [--paste]
plannotator-tui rex terminal [--block <id>] [--session <id>] [--lines N] [--paste] [--print]
plannotator-tui rex find [--block <id>] [--session <id>] [--lines N] [--paste]
plannotator-tui rex pane (open <path> [--gate [--result-file <path>]] | last <last args>
| terminal --block <id> [--lines N])
plannotator-tui last [--host claude|codex|pi|omp|copilot|droid|hermes|opencode|cmd|agy] [--pid N] [--session <transcript>]
[--session-id <id>] [--stdin] [--print] [--pick N] [--newest]";

Expand All @@ -54,21 +62,25 @@ fn open_file(path: &PathBuf) -> Result<DocumentSource> {
}

/// A file opens on its own; a folder opens in folder mode on its first markdown file.
/// Interactive runs send to the Herdr agent pane named by the environment, else the
/// Interactive runs send to the Rex block or Herdr agent pane named by the environment, else the
/// clipboard; headless runs send nowhere.
pub(crate) fn delivery(interactive: bool) -> Box<dyn Delivery> {
if !interactive {
return Box::new(Discard);
}
herdr_agent().unwrap_or_else(|| Box::new(Clipboard))
agent_pane().unwrap_or_else(|| Box::new(Clipboard))
}

/// A gated review sends to the same agent pane; without one its decision goes to the caller.
fn gate_delivery() -> Box<dyn Delivery> {
herdr_agent().unwrap_or_else(|| Box::new(Caller))
agent_pane().unwrap_or_else(|| Box::new(Caller))
}

fn herdr_agent() -> Option<Box<dyn Delivery>> {
/// The Rex block or Herdr agent pane the environment names, if any.
fn agent_pane() -> Option<Box<dyn Delivery>> {
if let Some(agent) = RexAgent::from_lookup(|k| std::env::var(k).ok()) {
return Some(Box::new(agent));
}
let env = HerdrEnv::from_env();
match env.delivery_target() {
Some(target) if env.in_herdr => {
Expand Down Expand Up @@ -135,7 +147,9 @@ fn wants_decision(args: &[String]) -> bool {
let decision_flag = |arg: &String| gate::FLAGS.contains(&arg.as_str());
match args.first() {
Some(first) if decision_flag(first) => true,
Some(first) if first.starts_with('-') || matches!(first.as_str(), "config" | "herdr" | "last") => {
Some(first)
if first.starts_with('-') || matches!(first.as_str(), "config" | "herdr" | "rex" | "last") =>
{
false
}
Some(_) => args.iter().skip(1).any(decision_flag),
Expand Down Expand Up @@ -189,6 +203,7 @@ fn tool(args: &[String]) -> Result<()> {
}
Some("config") => show_config(),
Some("herdr") => herdr_command(args.get(1..).unwrap_or_default()),
Some("rex") => crate::rex::cli::run(args.get(1..).unwrap_or_default()),
Some("last") => last_command(args.get(1..).unwrap_or_default()),
Some(flag) if flag.starts_with("--") => anyhow::bail!("unknown flag {flag}\n{USAGE}"),
Some(_) => interactive(&path(0)?),
Expand Down Expand Up @@ -333,18 +348,12 @@ fn herdr_terminal(args: &[String]) -> Result<()> {
use crate::herdr::launch::{OpenArgs, TerminalRead, plan_terminal, run};
use crate::herdr::terminal;
let mut open = OpenArgs::default();
let mut lines = terminal::DEFAULT_LINES;
let mut lines = crate::terminal::DEFAULT_LINES;
let mut print = false;
let mut rest = args.iter();
while let Some(arg) = rest.next() {
match arg.as_str() {
"--lines" => {
lines = rest
.next()
.and_then(|n| n.parse::<u32>().ok())
.filter(|n| *n >= 1)
.context("--lines takes a number from 1")?;
}
"--lines" => lines = lines_value(rest.next())?,
"--placement" => {
let value = rest.next().context("--placement needs a value")?;
open.placement = Some(value.parse()?);
Expand All @@ -360,7 +369,7 @@ fn herdr_terminal(args: &[String]) -> Result<()> {
let pane = env.reviewed_pane().context("no focused pane to read")?;
let text = terminal::read(&env, &pane, lines)?;
if print {
print!("{}", terminal::document(&text));
print!("{}", crate::terminal::document(&text));
return Ok(());
}
let config = Config::load()?;
Expand Down Expand Up @@ -390,19 +399,29 @@ fn herdr_pane() -> Result<()> {
};
// The pane closes when we exit; an error that flashes by is an error nobody can read.
if let Err(err) = &result {
#[allow(clippy::print_stderr, reason = "the pane is the only place this can be read")]
{
eprintln!("plannotator-tui: {err:#}\n\npress enter to close");
}
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
wait_on_error(err);
}
result
}

/// `--lines`: a count from 1.
pub(crate) fn lines_value(value: Option<&String>) -> Result<u32> {
value.and_then(|n| n.parse::<u32>().ok()).filter(|n| *n >= 1).context("--lines takes a number from 1")
}

/// Show `err` in a pane that is about to close, until Enter.
pub(crate) fn wait_on_error(err: &anyhow::Error) {
#[allow(clippy::print_stderr, reason = "the pane is the only place this can be read")]
{
eprintln!("plannotator-tui: {err:#}\n\npress enter to close");
}
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
}

/// `plannotator-tui last [--host H] [--pid N] [--session PATH] [--stdin] [--print] [--pick N]
/// [--newest]`.
fn last_command(args: &[String]) -> Result<()> {
pub(crate) fn last_command(args: &[String]) -> Result<()> {
use crate::last::LastOptions;
let mut options = LastOptions { pick: 25, ..LastOptions::default() };
let mut rest = args.iter();
Expand All @@ -426,9 +445,9 @@ fn last_command(args: &[String]) -> Result<()> {
crate::last::run(&options)
}

/// A gated review in a Herdr pane: the agent hears the decision through delivery, and the
/// A gated review in a Herdr or Rex pane: the agent hears the decision through delivery, and the
/// result file, when asked for, records it. The pane has no caller reading its stdout.
fn gated_pane(path: &PathBuf, result_file: Option<&Path>) -> Result<()> {
pub(crate) fn gated_pane(path: &PathBuf, result_file: Option<&Path>) -> Result<()> {
if let Some(file) = result_file {
gate::check_result_file(file)?;
}
Expand All @@ -444,7 +463,7 @@ fn gated_pane(path: &PathBuf, result_file: Option<&Path>) -> Result<()> {
}
}

fn interactive(path: &PathBuf) -> Result<()> {
pub(crate) fn interactive(path: &PathBuf) -> Result<()> {
run_ui(|width| open_app(path, width, true))
}

Expand Down
14 changes: 10 additions & 4 deletions crates/plannotator-tui/src/delivery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
//!
//! The app renders feedback with one function regardless of target; only the target
//! varies. Clipboard is the standalone default. Inside Herdr the target is an agent's pane
//! and the transport is `herdr agent prompt`. Nothing else in the app knows which is in use;
//! it only distinguishes the three outcomes below.
//! and the transport is `herdr agent prompt`; inside Rex it is an agent's block and the `rex`
//! CLI (`crate::rex::agent`). Nothing else in the app knows which is in use; it only
//! distinguishes the three outcomes below.

use std::io::Write as _;
use std::path::PathBuf;
Expand All @@ -12,9 +13,9 @@ use std::process::{Command, Stdio};
/// Why a send did not land. The app reacts differently to each.
#[derive(Debug)]
pub(crate) enum DeliveryError {
/// The agent is at a dialog and Herdr refused the prompt; retry later.
/// The agent is at a dialog and its host (Herdr or Rex) refused the prompt; retry later.
Blocked(String),
/// No agent to send to: pane gone, agent not detected yet, herdr binary missing.
/// No agent to send to: pane gone, agent not detected yet, herdr or rex binary missing.
Unavailable(String),
/// Anything else, with whatever the transport said.
Failed(anyhow::Error),
Expand Down Expand Up @@ -47,6 +48,11 @@ pub(crate) trait Delivery {
None
}
fn deliver(&self, feedback: &str) -> Result<(), DeliveryError>;
/// What the person still has to do after the last `deliver` succeeded, shown with its
/// status: the feedback reached the agent but did not finish, e.g. it waits unsubmitted.
fn notice(&self) -> Option<String> {
None
}
}

/// The OSC 52 sequence that hands `text` to the terminal's clipboard.
Expand Down
2 changes: 1 addition & 1 deletion crates/plannotator-tui/src/herdr/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ fn program_name(name: &str) -> &str {
}

/// Agents whose transcripts the hosts crate can read, by process name.
fn known_host(name: &str) -> Option<&'static str> {
pub(crate) fn known_host(name: &str) -> Option<&'static str> {
match program_name(name) {
"claude" => Some("claude"),
"codex" => Some("codex"),
Expand Down
Loading
Loading