From b6e6588ccb6fe6c9fb73df40676b38ad540e3196 Mon Sep 17 00:00:00 2001 From: Kyle Wigley <9877221+kwigley@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:22:29 -0400 Subject: [PATCH 1/7] feat(rex): open reviews in rex and send feedback back Add `plannotator-tui rex last|open|terminal`. Each command finds the agent in a Rex block, opens plannotator-tui in a split to the right of the block, and exits. The split is an ordinary pane, so it keeps the frame and theme of Rex. When an agent runs in the block, a send pastes the feedback into the block as one bracketed paste and presses Enter. Without an agent, the review copies to the clipboard. - The block is --block, else REX_BLOCK. An agent that runs a launcher in its own shell hands the human a document to review. - --paste leaves the feedback in the agent's prompt and focuses the agent's block, so the human can edit it before sending. - A send waits while Rex reports the agent blocked on a permission prompt or a question, like agent_blocked in Herdr. - A Rex action runs commands with a bare PATH. The launcher then runs the rex binary of its parent process, the Rex server. - `rex pane` is the entry point of the split. An error waits for Enter, because Rex closes the split when the program exits. Move the terminal review out of src/herdr into src/terminal.rs, because Herdr and Rex now share it. Each host only reads the output. --- AGENTS.md | 2 + README.md | 22 ++ crates/plannotator-tui/src/cli.rs | 58 +++-- crates/plannotator-tui/src/herdr/launch.rs | 2 +- crates/plannotator-tui/src/herdr/terminal.rs | 233 +------------------ crates/plannotator-tui/src/main.rs | 2 + crates/plannotator-tui/src/rex/agent.rs | 137 +++++++++++ crates/plannotator-tui/src/rex/cli.rs | 87 +++++++ crates/plannotator-tui/src/rex/command.rs | 170 ++++++++++++++ crates/plannotator-tui/src/rex/launch.rs | 228 ++++++++++++++++++ crates/plannotator-tui/src/rex/mod.rs | 11 + crates/plannotator-tui/src/rex/terminal.rs | 46 ++++ crates/plannotator-tui/src/terminal.rs | 232 ++++++++++++++++++ skills/plannotator-tui/SKILL.md | 7 +- 14 files changed, 981 insertions(+), 256 deletions(-) create mode 100644 crates/plannotator-tui/src/rex/agent.rs create mode 100644 crates/plannotator-tui/src/rex/cli.rs create mode 100644 crates/plannotator-tui/src/rex/command.rs create mode 100644 crates/plannotator-tui/src/rex/launch.rs create mode 100644 crates/plannotator-tui/src/rex/mod.rs create mode 100644 crates/plannotator-tui/src/rex/terminal.rs create mode 100644 crates/plannotator-tui/src/terminal.rs diff --git a/AGENTS.md b/AGENTS.md index 3bf4a96..29c881f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. +src/rex/ the Rex launchers (`plannotator-tui rex …`) and delivery, driven + through the `rex` CLI. ``` Markdown parsing is `pulldown-cmark`; rendering to styled text is `tui-markdown`. We never diff --git a/README.md b/README.md index d4f139f..207eadf 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,28 @@ 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 +``` + +The pane is `--block `, 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`. A send waits while Rex reports the agent blocked on +a permission prompt or a question, as it does in Herdr. 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 diff --git a/crates/plannotator-tui/src/cli.rs b/crates/plannotator-tui/src/cli.rs index 6f2e73f..e2705de 100644 --- a/crates/plannotator-tui/src/cli.rs +++ b/crates/plannotator-tui/src/cli.rs @@ -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 [--gate] [--json] [--require-approval] [--result-file ] plannotator-tui --export plannotator-tui --bench @@ -40,6 +41,10 @@ const USAGE: &str = "usage: plannotator-tui herdr last [--placement P] [--deliver-to ] [--newest] plannotator-tui herdr terminal [--lines N] [--placement P] [--deliver-to ] [--print] plannotator-tui herdr pane + plannotator-tui rex open [file.md | folder] [--block ] [--session ] [--paste] + plannotator-tui rex last [--block ] [--session ] [--newest] [--paste] + plannotator-tui rex terminal [--block ] [--session ] [--lines N] [--paste] [--print] + plannotator-tui rex pane (open | last | terminal --block [--lines N]) plannotator-tui last [--host claude|codex|pi|omp|copilot|droid|hermes|opencode|cmd|agy] [--pid N] [--session ] [--session-id ] [--stdin] [--print] [--pick N] [--newest]"; @@ -54,21 +59,25 @@ fn open_file(path: &PathBuf) -> Result { } /// 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 { 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 { - herdr_agent().unwrap_or_else(|| Box::new(Caller)) + agent_pane().unwrap_or_else(|| Box::new(Caller)) } -fn herdr_agent() -> Option> { +/// The Rex block or Herdr agent pane the environment names, if any. +fn agent_pane() -> Option> { + 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 => { @@ -189,6 +198,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)?), @@ -333,18 +343,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::().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()?); @@ -360,7 +364,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()?; @@ -390,19 +394,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 { + value.and_then(|n| n.parse::().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(); @@ -444,7 +458,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)) } diff --git a/crates/plannotator-tui/src/herdr/launch.rs b/crates/plannotator-tui/src/herdr/launch.rs index d2642be..4461008 100644 --- a/crates/plannotator-tui/src/herdr/launch.rs +++ b/crates/plannotator-tui/src/herdr/launch.rs @@ -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"), diff --git a/crates/plannotator-tui/src/herdr/terminal.rs b/crates/plannotator-tui/src/herdr/terminal.rs index 1b32f31..f23a788 100644 --- a/crates/plannotator-tui/src/herdr/terminal.rs +++ b/crates/plannotator-tui/src/herdr/terminal.rs @@ -1,21 +1,12 @@ -//! `plannotator-tui herdr terminal`: review a pane's recent terminal output. -//! -//! Herdr reads the pane (`herdr pane read`); this module cleans the text and presents it as -//! one fenced code block, so the output shows verbatim and a quote in the feedback is the -//! text as it appeared. The document is transient: nothing is written to disk. +//! `plannotator-tui herdr terminal`: review a pane's recent terminal output, read through +//! `herdr pane read`. `crate::terminal` turns the text into the review. use std::process::{Command, Stdio}; use anyhow::{Context, Result}; -use plannotator_tui_schema::{DocumentSource, Provenance}; -use unicode_width::UnicodeWidthChar; use super::context::HerdrEnv; -use crate::app::App; -use crate::delivery::Delivery; - -/// Recent lines read when `--lines` is not given. Herdr caps a read at 1000. -pub(crate) const DEFAULT_LINES: u32 = 200; +use crate::terminal::{DEFAULT_LINES, clean, open, title}; /// `herdr pane read --source recent-unwrapped`, cleaned; an error when Herdr fails /// or the pane shows nothing. Unwrapped lines are the program's own lines, so a split that @@ -36,138 +27,6 @@ pub(crate) fn read(env: &HerdrEnv, pane: &str, lines: u32) -> Result { Ok(text) } -/// Terminal text as plain lines: escape sequences and control characters removed, trailing -/// whitespace trimmed, and blank lines dropped from both ends. -pub(crate) fn clean(raw: &str) -> String { - let plain = strip_escapes(raw); - let lines: Vec<&str> = plain.lines().map(str::trim_end).collect(); - let first = lines.iter().position(|line| !line.is_empty()); - let last = lines.iter().rposition(|line| !line.is_empty()); - match (first, last) { - (Some(first), Some(last)) => lines.get(first..=last).unwrap_or_default().join("\n"), - _ => String::new(), - } -} - -/// Drop CSI, OSC, and two-byte escape sequences, and every control character but `\n` and -/// `\t`. `\r\n` becomes `\n`; a lone `\r` is dropped. -fn strip_escapes(raw: &str) -> String { - let mut out = String::with_capacity(raw.len()); - let mut chars = raw.chars().peekable(); - while let Some(ch) = chars.next() { - match ch { - '\u{1b}' => match chars.next() { - // CSI: parameters and intermediates up to one final byte in `@`..=`~`. - Some('[') => { - for c in chars.by_ref() { - if ('@'..='~').contains(&c) { - break; - } - } - } - // OSC: up to BEL or ST (`ESC \`). - Some(']') => { - while let Some(c) = chars.next() { - if c == '\u{7}' || (c == '\u{1b}' && chars.next_if_eq(&'\\').is_some()) { - break; - } - } - } - _ => {} - }, - '\n' | '\t' => out.push(ch), - c if c.is_control() => {} - c => out.push(c), - } - } - out -} - -/// Output re-broken to fit a width, and where it was broken inside a token. -#[derive(Debug, PartialEq, Eq)] -pub(crate) struct Wrapped { - pub(crate) text: String, - /// Byte offsets in `text` of the newlines that split a token wider than the view. A - /// quote across one rejoins without a space; a break at a space rejoins with one. - pub(crate) breaks: Vec, -} - -/// Break lines wider than `width` columns, at the last space that fits when there is one. -/// Code blocks clip rather than wrap, so without this a long line would be cut off. -pub(crate) fn wrap(text: &str, width: usize) -> Wrapped { - let width = width.max(1); - let mut out = String::with_capacity(text.len()); - let mut breaks = Vec::new(); - for (i, line) in text.lines().enumerate() { - if i > 0 { - out.push('\n'); - } - let mut rest = line; - while let Some(cut) = overflow(rest, width) { - let head = rest.get(..cut).unwrap_or(rest); - // A space inside the leading indentation is no place to break. - let indent = head.len() - head.trim_start_matches(' ').len(); - if let Some(space) = head.rfind(' ').filter(|&space| space > indent) { - out.push_str(head.get(..space).unwrap_or(head)); - rest = rest.get(space + 1..).unwrap_or_default(); - } else { - out.push_str(head); - breaks.push(out.len()); - rest = rest.get(cut..).unwrap_or_default(); - } - out.push('\n'); - } - out.push_str(rest); - } - Wrapped { text: out, breaks } -} - -/// The byte offset where `line` stops fitting in `width` columns, or `None` when it fits. -fn overflow(line: &str, width: usize) -> Option { - let mut used = 0; - for (offset, ch) in line.char_indices() { - used += UnicodeWidthChar::width(ch).unwrap_or(0); - // A character wider than the whole line still has to go somewhere. - if used > width && offset > 0 { - return Some(offset); - } - } - None -} - -/// The output as Markdown: one fenced `text` block, its fence longer than any backtick run -/// inside so the output cannot close it. -pub(crate) fn document(text: &str) -> String { - let longest = text.split(|c| c != '`').map(str::len).max().unwrap_or(0); - let fence = "`".repeat(longest.max(2) + 1); - format!("{fence}text\n{text}\n{fence}\n") -} - -/// What the header calls the review: `terminal · claude in w1:p2`, or the pane alone. -pub(crate) fn title(pane: &str, agent: Option<&str>) -> String { - match agent { - Some(agent) => format!("terminal · {agent} in {pane}"), - None => format!("terminal · {pane}"), - } -} - -/// The document the app opens: transient, so it gets no sidecar, history, or drafts. -pub(crate) fn source(text: &str, title: String) -> DocumentSource { - DocumentSource::new(document(text), title, true, Provenance::Stdin) -} - -/// The review of `text` at `width`: wrapped to fit, with the breaks the wrapper put inside -/// tokens handed to the app so quotes across them read as printed. -pub(crate) fn open(text: &str, title: String, width: usize, delivery: Box) -> Result { - let wrapped = wrap(text, width); - let source = source(&wrapped.text, title); - // The output starts after the opening fence line. - let start = source.content.find('\n').map_or(0, |newline| newline + 1); - let mut app = App::open(source, width, delivery)?; - app.set_terminal_breaks(wrapped.breaks.iter().map(|offset| start + offset).collect()); - Ok(app) -} - /// The doc pane for `PLANNOTATOR_TUI_TERMINAL_PANE`: read the pane now and open the review. /// Feedback goes where the launcher said (`PLANNOTATOR_TUI_DELIVER_TO`), else the clipboard. pub(crate) fn run(env: &HerdrEnv, pane: &str) -> Result<()> { @@ -176,89 +35,3 @@ pub(crate) fn run(env: &HerdrEnv, pane: &str) -> Result<()> { let title = title(pane, agent.as_deref()); crate::cli::run_ui(|width| open(&text, title, width, crate::cli::delivery(true))) } - -#[cfg(test)] -#[allow(clippy::expect_used, reason = "tests assert by panicking")] -mod tests { - use super::*; - - #[test] - fn cleaning_removes_escapes_controls_and_surrounding_blank_lines() { - let raw = "\n \n\u{1b}[1;32m$ cargo test\u{1b}[0m \r\n\u{1b}]8;;file:///x\u{7}link\u{1b}]8;;\u{1b}\\ ok\u{7}\n\t indented\n\n\n"; - assert_eq!(clean(raw), "$ cargo test\nlink ok\n\t indented"); - } - - #[test] - fn output_with_nothing_visible_cleans_to_empty() { - assert_eq!(clean(""), ""); - assert_eq!(clean("\n \n\u{1b}[2J\n"), ""); - } - - #[test] - fn blank_lines_inside_the_output_are_kept() { - assert_eq!(clean("one\n\n\ntwo\n"), "one\n\n\ntwo"); - } - - #[test] - fn long_lines_break_at_a_space_and_short_lines_are_untouched() { - let text = |input: &str, width| wrap(input, width).text; - assert_eq!( - wrap("the quick brown fox", 10), - Wrapped { text: "the quick\nbrown fox".into(), breaks: vec![] } - ); - assert_eq!(text("short\n indented", 10), "short\n indented"); - assert_eq!(wrap("abcdefghijkl", 5), Wrapped { text: "abcde\nfghij\nkl".into(), breaks: vec![5, 11] }); - assert_eq!(text(" abcdefghi", 6), " abc\ndefghi"); - // Wide characters count as two columns. - assert_eq!(text("한글한글", 4), "한글\n한글"); - } - - #[test] - fn the_fence_outlasts_any_backticks_in_the_output() { - assert_eq!(document("ls"), "```text\nls\n```\n"); - let doc = document("echo ```` done"); - assert!(doc.starts_with("`````text\n") && doc.ends_with("\n`````\n"), "{doc}"); - } - - #[test] - fn the_document_renders_the_output_as_one_code_block() { - let doc = crate::doc::Document::parse(document("# not a heading\n* not a list")); - let kinds: Vec<_> = doc.blocks.iter().map(|block| block.kind).collect(); - assert_eq!(kinds, [crate::doc::BlockKind::CodeBlock]); - } - - #[test] - fn the_review_is_transient_and_titled_by_its_pane() { - let source = source("ok", title("w1:p2", Some("claude"))); - assert!(source.transient); - assert_eq!(source.name, "terminal · claude in w1:p2"); - assert_eq!(title("w1:p2", None), "terminal · w1:p2"); - } - - #[test] - fn a_quote_across_a_broken_path_reads_as_printed() { - let path = "/home/user/projects/herdr/src/deeply/nested/module/source.rs"; - assert_eq!(path.len(), 60); - let text = format!("error in {path}\nok"); - let wrapped = wrap(&text, 24); - // `error in` breaks at its space; the path breaks twice inside itself. - assert_eq!(wrapped.text.lines().count(), 5, "{}", wrapped.text); - assert_eq!(wrapped.breaks.len(), 2); - let across: Vec<&str> = wrapped.text.lines().take(4).collect(); - let mut app = - open(&text, title("w1:p1", None), 24, Box::new(crate::delivery::Discard)).expect("opens"); - app.add_quote_annotation_at( - &across.join("\n"), - 1, - plannotator_tui_schema::Kind::Comment, - "fix".into(), - ) - .expect("annotated"); - assert_eq!( - app.feedback(), - format!( - "# Annotations on terminal · w1:p1\n\n## Annotation 1\nComment on: \"error in {path}\"\n> fix\n\n" - ) - ); - } -} diff --git a/crates/plannotator-tui/src/main.rs b/crates/plannotator-tui/src/main.rs index 40a5f2b..247c85e 100644 --- a/crates/plannotator-tui/src/main.rs +++ b/crates/plannotator-tui/src/main.rs @@ -15,8 +15,10 @@ mod herdr; mod keys; mod last; mod layout; +mod rex; mod srcmap; mod store; +mod terminal; mod theme; mod tree; mod workspace_paths; diff --git a/crates/plannotator-tui/src/rex/agent.rs b/crates/plannotator-tui/src/rex/agent.rs new file mode 100644 index 0000000..c5154e3 --- /dev/null +++ b/crates/plannotator-tui/src/rex/agent.rs @@ -0,0 +1,137 @@ +//! An agent running in a Rex block, as a delivery target. +//! +//! Sending pastes the feedback into the block as a paste event, so Rex wraps it in the +//! bracketed-paste markers the agent asked for, then presses Enter. In paste mode it leaves +//! Enter to you: the feedback waits in the agent's prompt and the block takes focus, ready to +//! edit. Before writing anything it asks the block's `program_status` whether the agent is at +//! a dialog, the same refusal Herdr gives with `agent_blocked`. + +use serde_json::{Value, json}; + +use super::command::{Rex, RexError, short_id}; +use crate::delivery::{Delivery, DeliveryError}; + +/// `PLANNOTATOR_TUI_REX_BLOCK`: the block to send feedback to, set by `plannotator-tui rex`. +pub(crate) const BLOCK_VAR: &str = "PLANNOTATOR_TUI_REX_BLOCK"; +/// `PLANNOTATOR_TUI_DELIVER_AGENT`: the agent's host label, for the footer and the archive. +pub(crate) const AGENT_VAR: &str = "PLANNOTATOR_TUI_DELIVER_AGENT"; +/// `PLANNOTATOR_TUI_REX_SUBMIT=0`: paste into the agent's prompt without pressing Enter. +pub(crate) const SUBMIT_VAR: &str = "PLANNOTATOR_TUI_REX_SUBMIT"; + +#[derive(Debug)] +pub(crate) struct RexAgent { + rex: Rex, + block: String, + agent: Option, + /// Press Enter after the paste; `false` leaves the feedback in the prompt to edit. + submit: bool, +} + +impl RexAgent { + /// The target `plannotator-tui rex` named in the environment, or `None` outside such a layer. + pub(crate) fn from_lookup(env: impl Fn(&str) -> Option) -> Option { + let non_empty = |k: &str| env(k).filter(|v| !v.is_empty()); + let block = non_empty(BLOCK_VAR)?; + let rex = Rex::from_lookup(&env, None).ok()?; + let submit = env(SUBMIT_VAR).as_deref() != Some("0"); + Some(Self { rex, block, agent: non_empty(AGENT_VAR), submit }) + } +} + +impl Delivery for RexAgent { + fn agent_host(&self) -> Option<&str> { + self.agent.as_deref() + } + + fn describe(&self) -> String { + let block = short_id(&self.block); + let who = self.agent.as_deref().unwrap_or("agent"); + if self.submit { format!("{who} in {block}") } else { format!("{who} prompt in {block}") } + } + + fn is_agent(&self) -> bool { + true + } + + fn deliver(&self, feedback: &str) -> Result<(), DeliveryError> { + let status = self.rex.terminal_method(&self.block, "program_status").map_err(unavailable)?; + if let Some(reason) = blocked_reason(&status) { + return Err(DeliveryError::Blocked(format!("{} is {reason}", self.describe()))); + } + let paste = json!({ + "block_id": self.block, + "event": { "type": "paste", "payload": { "text": feedback } }, + }); + self.rex.api("block.event", &paste).map_err(unavailable)?; + let next: &[&str] = + if self.submit { &["send-key", "-b", &self.block, "enter"] } else { &["focus", &self.block] }; + self.rex.run(next, None).map_err(unavailable)?; + Ok(()) + } +} + +fn unavailable(err: RexError) -> DeliveryError { + match err { + RexError::NotFound(message) => DeliveryError::Unavailable(message), + RexError::Failed(err) => DeliveryError::Failed(err), + } +} + +/// What a blocked agent is waiting for, from `program_status` JSON; `None` when it can take a +/// message. A record Rex marks `blocked` is the agent at a permission prompt or a question. +pub(crate) fn blocked_reason(status: &Value) -> Option { + let records = status.get("records")?.as_array()?; + let blocked = records.iter().find(|r| r.get("state").and_then(Value::as_str) == Some("blocked"))?; + Some(match blocked.get("kind").and_then(Value::as_str) { + Some("permission") => "waiting for a permission answer".to_owned(), + Some("question") => "waiting for an answer to its question".to_owned(), + Some("auth") => "waiting for a credential".to_owned(), + _ => "waiting for you".to_owned(), + }) +} + +#[cfg(test)] +#[allow(clippy::expect_used, reason = "tests assert by panicking")] +mod tests { + use super::*; + + fn agent(vars: &[(&str, &str)]) -> Option { + let owned: Vec<(String, String)> = + vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(); + RexAgent::from_lookup(|k| owned.iter().find(|(name, _)| name == k).map(|(_, v)| v.clone())) + } + + #[test] + fn only_a_named_block_inside_a_rex_session_is_a_target() { + let block = "block:01a17dd5-e47c-754f-b72e-ef739ea1d6db"; + assert!(agent(&[("REX_SESSION", "session:a")]).is_none()); + assert!(agent(&[(BLOCK_VAR, block)]).is_none()); + let target = agent(&[("REX_SESSION", "session:a"), (BLOCK_VAR, block), (AGENT_VAR, "claude")]) + .expect("a target"); + assert_eq!(target.describe(), "claude in a1d6db"); + assert_eq!(target.agent_host(), Some("claude")); + } + + #[test] + fn only_an_explicit_zero_turns_sending_into_pasting_and_the_target_says_so() { + let block = "block:01a17dd5-e47c-754f-b72e-ef739ea1d6db"; + let with = |submit: &'static str| { + agent(&[("REX_SESSION", "s"), (BLOCK_VAR, block), (AGENT_VAR, "claude"), (SUBMIT_VAR, submit)]) + .expect("a target") + }; + assert!(with("1").submit && with("").submit); + assert!(!with("0").submit); + assert_eq!(with("0").describe(), "claude prompt in a1d6db"); + } + + #[test] + fn a_working_or_finished_agent_takes_messages_and_a_blocked_one_does_not() { + let working = json!({"records":[{"state":"working","app":"claude-code"}]}); + assert_eq!(blocked_reason(&working), None); + assert_eq!(blocked_reason(&json!({"records":[]})), None); + let asking = json!({"records":[{"state":"done"},{"id":"x","state":"blocked","kind":"permission"}]}); + assert_eq!(blocked_reason(&asking).as_deref(), Some("waiting for a permission answer")); + let unknown = json!({"records":[{"state":"blocked","kind":"telepathy"}]}); + assert_eq!(blocked_reason(&unknown).as_deref(), Some("waiting for you")); + } +} diff --git a/crates/plannotator-tui/src/rex/cli.rs b/crates/plannotator-tui/src/rex/cli.rs new file mode 100644 index 0000000..93c44bf --- /dev/null +++ b/crates/plannotator-tui/src/rex/cli.rs @@ -0,0 +1,87 @@ +//! `plannotator-tui rex …`: the launchers a Rex action or an agent runs, and `rex pane`, the +//! entry point of the split they open. + +#![allow(clippy::print_stdout, reason = "`--print` is a stdout contract")] + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +use super::command::Rex; +use super::launch::{self, Delivery, Review}; +use crate::cli::{USAGE, interactive, last_command, lines_value, wait_on_error}; + +/// `plannotator-tui rex open|last|terminal`: open a review in a split beside a block. The block +/// defaults to `REX_BLOCK`, so an agent running this from its own block reviews itself. +/// `--paste` leaves the feedback in the agent's prompt instead of sending it. `rex pane` is +/// what the split runs. +pub(crate) fn run(args: &[String]) -> Result<()> { + let mut review = match args.first().map(String::as_str) { + Some("last") => Review::Last { newest: false }, + Some("open") => Review::Open(None), + Some("terminal") => Review::Terminal { lines: crate::terminal::DEFAULT_LINES }, + Some("pane") => return pane(args.get(1..).unwrap_or_default()), + _ => anyhow::bail!(USAGE), + }; + let mut block = None; + let mut session = None; + let mut delivery = Delivery::Send; + let mut print = false; + let mut rest = args.get(1..).unwrap_or_default().iter(); + while let Some(arg) = rest.next() { + match (arg.as_str(), &mut review) { + ("--block", _) => block = Some(rest.next().context("--block needs a value")?.clone()), + ("--session", _) => session = Some(rest.next().context("--session needs a value")?.clone()), + ("--paste", _) => delivery = Delivery::Paste, + ("--newest", Review::Last { newest }) => *newest = true, + ("--lines", Review::Terminal { lines }) => *lines = lines_value(rest.next())?, + ("--print", Review::Terminal { .. }) => print = true, + (flag, _) if flag.starts_with("--") => anyhow::bail!("unknown flag {flag}\n{USAGE}"), + (path, Review::Open(open @ None)) => *open = Some(std::path::absolute(path)?), + (extra, _) => anyhow::bail!("unexpected argument {extra:?}\n{USAGE}"), + } + } + let block = block + .or_else(|| std::env::var("REX_BLOCK").ok().filter(|b| !b.is_empty())) + .context("no Rex block: pass --block or run inside Rex (REX_BLOCK is not set)")?; + let rex = Rex::from_lookup(|k| std::env::var(k).ok(), session)?; + if let (true, Review::Terminal { lines }) = (print, &review) { + print!("{}", crate::terminal::document(&super::terminal::read(&rex, &block, *lines)?)); + return Ok(()); + } + launch::run(&rex, &block, &review, delivery) +} + +/// The split's entry point. Rex closes the split when this exits, so an error waits for +/// Enter rather than flash by unread. +fn pane(args: &[String]) -> Result<()> { + let result = match args.first().map(String::as_str) { + Some("last") => last_command(args.get(1..).unwrap_or_default()), + Some("open") => match args.get(1..) { + Some([path]) => interactive(&PathBuf::from(path)), + _ => Err(anyhow::anyhow!("rex pane open takes one path\n{USAGE}")), + }, + Some("terminal") => pane_terminal(args.get(1..).unwrap_or_default()), + _ => Err(anyhow::anyhow!(USAGE)), + }; + if let Err(err) = &result { + wait_on_error(err); + } + result +} + +fn pane_terminal(args: &[String]) -> Result<()> { + let mut block = None; + let mut lines = crate::terminal::DEFAULT_LINES; + let mut rest = args.iter(); + while let Some(arg) = rest.next() { + match arg.as_str() { + "--block" => block = Some(rest.next().context("--block needs a value")?.clone()), + "--lines" => lines = lines_value(rest.next())?, + other => anyhow::bail!("unexpected argument {other:?}\n{USAGE}"), + } + } + let block = block.context("rex pane terminal needs --block")?; + let rex = Rex::from_lookup(|k| std::env::var(k).ok(), None)?; + super::terminal::run(&rex, &block, lines) +} diff --git a/crates/plannotator-tui/src/rex/command.rs b/crates/plannotator-tui/src/rex/command.rs new file mode 100644 index 0000000..37a58a8 --- /dev/null +++ b/crates/plannotator-tui/src/rex/command.rs @@ -0,0 +1,170 @@ +//! The `rex` binary, scoped to one session. + +use std::io::Write as _; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result}; +use serde_json::Value; + +/// The exit status `rex` uses when a session or block it was given does not exist. +const NOT_FOUND: i32 = 3; + +/// Why a `rex` call failed. +#[derive(Debug)] +pub(crate) enum RexError { + /// The session or block is gone. + NotFound(String), + /// `rex` could not be started, or it failed for another reason. + Failed(anyhow::Error), +} + +impl std::fmt::Display for RexError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound(msg) => f.write_str(msg), + Self::Failed(err) => write!(f, "{err:#}"), + } + } +} + +impl std::error::Error for RexError {} + +/// The `rex` to run. Inside a Rex terminal that is `$REX_BIN_DIR/rex`, the server's own copy. +/// A Rex action runs commands with a bare `PATH` and no `REX_BIN_DIR`, but its parent is the +/// server, so `parent` names that binary. Anywhere else, `rex` on `PATH`. +fn bin(env: impl Fn(&str) -> Option, parent: impl FnOnce() -> Option) -> PathBuf { + env("REX_BIN_DIR") + .filter(|dir| !dir.is_empty()) + .map(|dir| PathBuf::from(dir).join("rex")) + .or_else(parent) + .unwrap_or_else(|| PathBuf::from("rex")) +} + +/// The parent process's executable, when it is a `rex`. +fn parent_rex() -> Option { + let exe = parent_exe()?; + (exe.file_name()? == "rex").then_some(exe) +} + +#[cfg(unix)] +fn parent_exe() -> Option { + let ppid = std::os::unix::process::parent_id(); + if let Ok(exe) = std::fs::read_link(format!("/proc/{ppid}/exe")) { + return Some(exe); + } + // macOS has no /proc; `ps` prints the full path of the executable. + let output = Command::new("/bin/ps").args(["-o", "comm=", "-p", &ppid.to_string()]).output().ok()?; + let path = String::from_utf8(output.stdout).ok()?; + let path = path.trim(); + (output.status.success() && path.starts_with('/')).then(|| PathBuf::from(path)) +} + +#[cfg(not(unix))] +fn parent_exe() -> Option { + None +} + +/// The last six characters of a Rex ID, the short form `rex ls` prints. +pub(crate) fn short_id(id: &str) -> &str { + id.char_indices().rev().nth(5).map_or(id, |(at, _)| id.get(at..).unwrap_or(id)) +} + +/// `rex`, with every call scoped to `session`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Rex { + bin: PathBuf, + session: String, +} + +impl Rex { + /// `rex` as `bin` finds it, scoped to `session` when given, else `REX_SESSION`. + pub(crate) fn from_lookup(env: impl Fn(&str) -> Option, session: Option) -> Result { + let non_empty = |k: &str| env(k).filter(|v| !v.is_empty()); + let session = session + .or_else(|| non_empty("REX_SESSION")) + .context("no Rex session: pass --session or run inside Rex (REX_SESSION is not set)")?; + Ok(Self { bin: bin(&env, parent_rex), session }) + } + + /// Run `rex ARGS -s SESSION`, writing `stdin` to it when given. Returns stdout. + pub(crate) fn run(&self, args: &[&str], stdin: Option<&str>) -> Result { + let mut child = Command::new(&self.bin) + .args(args) + .args(["-s", &self.session]) + .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| RexError::Failed(anyhow::anyhow!("cannot run {}: {err}", self.bin.display())))?; + if let (Some(text), Some(mut pipe)) = (stdin, child.stdin.take()) { + pipe.write_all(text.as_bytes()).map_err(|err| RexError::Failed(err.into()))?; + } + let output = child.wait_with_output().map_err(|err| RexError::Failed(err.into()))?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let message = stderr.trim().trim_start_matches("Error: ").to_owned(); + let command = args.iter().take(2).copied().collect::>().join(" "); + let message = if message.is_empty() { format!("rex {command} failed") } else { message }; + Err(match output.status.code() { + Some(NOT_FOUND) => RexError::NotFound(message), + _ => RexError::Failed(anyhow::anyhow!(message)), + }) + } + + /// Call one of a terminal block's methods, such as `process` or `program_status`. + pub(crate) fn terminal_method(&self, block: &str, method: &str) -> Result { + let method = format!("com.superlogical.terminal.{method}"); + let stdout = self.run(&["do", &method, "-b", block], None)?; + serde_json::from_str(&stdout).map_err(|err| { + RexError::Failed(anyhow::anyhow!("{method} returned something other than JSON: {err}")) + }) + } + + /// Call a server API method with `args`, sent on stdin so no text lands in `ps`. + pub(crate) fn api(&self, method: &str, args: &Value) -> Result { + let stdout = self.run(&["api", "call", method, "-"], Some(&args.to_string()))?; + serde_json::from_str(&stdout).map_err(|err| { + RexError::Failed(anyhow::anyhow!("{method} returned something other than JSON: {err}")) + }) + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, reason = "tests assert by panicking")] +mod tests { + use super::*; + + fn rex(vars: &[(&str, &str)], session: Option<&str>) -> Result { + let owned: Vec<(String, String)> = + vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(); + Rex::from_lookup( + |k| owned.iter().find(|(name, _)| name == k).map(|(_, v)| v.clone()), + session.map(str::to_owned), + ) + } + + #[test] + fn the_terminals_rex_then_the_parent_rex_then_path() { + let server = || Some(PathBuf::from("/app/MacOS/rex")); + let in_terminal = |k: &str| (k == "REX_BIN_DIR").then(|| "/opt/rex/bin".to_owned()); + assert_eq!(bin(in_terminal, server), PathBuf::from("/opt/rex/bin/rex")); + assert_eq!(bin(|_: &str| None, server), PathBuf::from("/app/MacOS/rex")); + assert_eq!(bin(|_: &str| None, || None), PathBuf::from("rex")); + } + + #[test] + fn a_short_id_is_the_last_six_characters() { + assert_eq!(short_id("block:01a17dd5-e47c-754f-b72e-ef739ea1d6db"), "a1d6db"); + assert_eq!(short_id("abc"), "abc"); + } + + #[test] + fn an_explicit_session_wins_and_none_at_all_is_an_error() { + let explicit = rex(&[("REX_SESSION", "session:a")], Some("session:b")).expect("rex"); + assert_eq!(explicit.session, "session:b"); + assert!(rex(&[], None).is_err()); + } +} diff --git a/crates/plannotator-tui/src/rex/launch.rs b/crates/plannotator-tui/src/rex/launch.rs new file mode 100644 index 0000000..2b6b7bf --- /dev/null +++ b/crates/plannotator-tui/src/rex/launch.rs @@ -0,0 +1,228 @@ +//! `plannotator-tui rex last|open|terminal`: find the agent in a Rex block and open +//! plannotator-tui in a split to its right, wired to send feedback back to the block. A split is an ordinary pane, +//! so it keeps Rex's frame and theme. `foreground`, `command` and `split` are pure; only `run` +//! touches Rex. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde_json::{Value, json}; + +use super::agent::{AGENT_VAR, BLOCK_VAR, SUBMIT_VAR}; +use super::command::Rex; +use crate::herdr::launch::known_host; + +/// What the opened pane reviews. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Review { + /// The agent's recent replies; `newest` skips the picker. + Last { newest: bool }, + /// A file or folder; the block's directory when `None`. + Open(Option), + /// The block's last `lines` lines of output. + Terminal { lines: u32 }, +} + +/// How feedback reaches the agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Delivery { + /// As its next message. + Send, + /// Into its prompt, for you to edit and submit. + Paste, +} + +/// The program in the foreground of a block, from `com.superlogical.terminal.process` JSON. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Foreground { + pub(crate) pid: u32, + pub(crate) cwd: Option, + /// The agent's host label when the program is one the hosts crate can read. + pub(crate) host: Option<&'static str>, +} + +/// Read the foreground process. Rex reports the binary's file name, which for Claude Code is +/// its version number, so the name it was invoked by is checked first. +pub(crate) fn foreground(process: &Value) -> Option { + let fg = process.get("foreground")?; + let pid = u32::try_from(fg.get("pid")?.as_u64()?).ok()?; + let host = ["argv0", "invoked_path", "name", "path"] + .iter() + .filter_map(|key| fg.get(*key).and_then(Value::as_str)) + .find_map(known_host); + let cwd = fg.get("cwd").and_then(Value::as_str).map(PathBuf::from); + Some(Foreground { pid, cwd, host }) +} + +/// The argv the pane runs: `env` sets the delivery target, then `plannotator-tui rex pane` +/// opens the review. +/// Without an agent in the block, feedback goes to the clipboard. +pub(crate) fn command( + exe: &Path, + block: &str, + fg: &Foreground, + review: &Review, + delivery: Delivery, +) -> Result> { + let mut argv = vec!["/usr/bin/env".to_owned()]; + if let Some(host) = fg.host { + argv.push(format!("{BLOCK_VAR}={block}")); + argv.push(format!("{AGENT_VAR}={host}")); + if delivery == Delivery::Paste { + argv.push(format!("{SUBMIT_VAR}=0")); + } + } + argv.extend([exe.display().to_string(), "rex".to_owned(), "pane".to_owned()]); + match review { + Review::Last { newest } => { + let host = fg.host.context("no supported agent runs in this block")?; + argv.extend(["last".to_owned(), "--pid".to_owned(), fg.pid.to_string()]); + argv.extend(["--host".to_owned(), host.to_owned()]); + if *newest { + argv.push("--newest".to_owned()); + } + } + Review::Open(path) => { + let path = path.clone().or_else(|| fg.cwd.clone()).context("no file or folder to open")?; + argv.extend(["open".to_owned(), path.display().to_string()]); + } + Review::Terminal { lines } => { + argv.extend(["terminal", "--block", block, "--lines"].map(str::to_owned)); + argv.push(lines.to_string()); + } + } + Ok(argv) +} + +/// The `session.new_split` arguments for `argv`, to the right of `block`, starting in `cwd`. +pub(crate) fn split(block: &str, cwd: Option<&Path>, argv: &[String]) -> Value { + let mut options = serde_json::Map::new(); + options.insert("shell".to_owned(), json!("none")); + options.insert("command".to_owned(), json!(argv)); + if let Some(cwd) = cwd { + options.insert("cwd".to_owned(), json!(cwd.display().to_string())); + } + json!({ + "anchor_id": block, + "direction": "horizontal", + "side": "after", + "focus": true, + "layout": { "block": { "flavor": "com.superlogical.terminal.shell", "label": "Annotate", "options": options } }, + }) +} + +/// Open the review in a split to the right of `block`. +pub(crate) fn run(rex: &Rex, block: &str, review: &Review, delivery: Delivery) -> Result<()> { + let process = rex.terminal_method(block, "process")?; + let fg = foreground(&process).with_context(|| format!("block {block} has no foreground process"))?; + let exe = std::env::current_exe().context("finding the plannotator-tui binary")?; + let argv = command(&exe, block, &fg, review, delivery)?; + rex.api("session.new_split", &split(block, fg.cwd.as_deref(), &argv))?; + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing, reason = "tests assert by panicking")] +mod tests { + use super::*; + + /// `com.superlogical.terminal.process` for a block running Claude Code, as Rex reports it. + const CLAUDE: &str = r#"{"last_exit":null,"child":{"pid":59515,"name":"login"},"foreground":{"pid":60223,"name":"2.1.308","path":"/Users/me/.local/share/claude/versions/2.1.308","invoked_path":"/Users/me/.local/bin/claude","argv0":"claude","cwd":"/Users/me/project"}}"#; + const SHELL: &str = r#"{"foreground":{"pid":700,"name":"fish","argv0":"-fish","cwd":"/Users/me"}}"#; + + fn parse(json: &str) -> Foreground { + foreground(&serde_json::from_str(json).expect("json")).expect("a foreground process") + } + + #[test] + fn claude_code_is_found_by_its_invoked_name_not_its_versioned_binary() { + let fg = parse(CLAUDE); + assert_eq!(fg.pid, 60223); + assert_eq!(fg.host, Some("claude")); + assert_eq!(fg.cwd.as_deref(), Some(Path::new("/Users/me/project"))); + assert_eq!(parse(SHELL).host, None); + } + + #[test] + fn last_reads_the_agents_pid_and_sends_back_to_its_block() { + let argv = command( + Path::new("/bin/pt"), + "block:b", + &parse(CLAUDE), + &Review::Last { newest: true }, + Delivery::Send, + ) + .expect("argv"); + assert_eq!( + argv, + [ + "/usr/bin/env", + "PLANNOTATOR_TUI_REX_BLOCK=block:b", + "PLANNOTATOR_TUI_DELIVER_AGENT=claude", + "/bin/pt", + "rex", + "pane", + "last", + "--pid", + "60223", + "--host", + "claude", + "--newest" + ] + ); + assert!( + command( + Path::new("/bin/pt"), + "block:b", + &parse(SHELL), + &Review::Last { newest: false }, + Delivery::Send + ) + .is_err() + ); + } + + #[test] + fn open_without_an_agent_reviews_the_blocks_folder_and_copies_to_the_clipboard() { + let argv = + command(Path::new("/bin/pt"), "block:b", &parse(SHELL), &Review::Open(None), Delivery::Send) + .expect("argv"); + assert_eq!(argv, ["/usr/bin/env", "/bin/pt", "rex", "pane", "open", "/Users/me"]); + } + + #[test] + fn pasting_is_passed_on_only_where_there_is_an_agent_to_paste_into() { + let paste = |json| { + command(Path::new("/bin/pt"), "block:b", &parse(json), &Review::Open(None), Delivery::Paste) + .expect("argv") + }; + assert!(paste(CLAUDE).contains(&"PLANNOTATOR_TUI_REX_SUBMIT=0".to_owned())); + assert!(!paste(SHELL).iter().any(|arg| arg.starts_with("PLANNOTATOR_TUI_REX"))); + } + + #[test] + fn a_terminal_review_reads_the_block_from_inside_the_split() { + let argv = command( + Path::new("/bin/pt"), + "block:b", + &parse(SHELL), + &Review::Terminal { lines: 80 }, + Delivery::Send, + ) + .expect("argv"); + assert_eq!( + argv, + ["/usr/bin/env", "/bin/pt", "rex", "pane", "terminal", "--block", "block:b", "--lines", "80"] + ); + } + + #[test] + fn the_review_opens_to_the_right_of_the_agents_block_and_runs_directly() { + let split = split("block:b", Some(Path::new("/tmp")), &["/bin/pt".to_owned()]); + assert_eq!(split["anchor_id"], "block:b"); + assert_eq!((&split["direction"], &split["side"]), (&json!("horizontal"), &json!("after"))); + assert_eq!(split["layout"]["block"]["options"]["shell"], "none"); + assert_eq!(split["layout"]["block"]["options"]["command"], json!(["/bin/pt"])); + assert_eq!(split["layout"]["block"]["options"]["cwd"], "/tmp"); + } +} diff --git a/crates/plannotator-tui/src/rex/mod.rs b/crates/plannotator-tui/src/rex/mod.rs new file mode 100644 index 0000000..a95a7c5 --- /dev/null +++ b/crates/plannotator-tui/src/rex/mod.rs @@ -0,0 +1,11 @@ +//! Running inside Rex: find the agent in a block, open ourselves in a split beside it, and +//! send the feedback back to that block. +//! +//! Rex is driven only through its `rex` CLI. `command` runs it; `launch` opens the split; +//! `terminal` reads a block's output; `agent` is the delivery target the opened app sends to. + +pub(crate) mod agent; +pub(crate) mod cli; +pub(crate) mod command; +pub(crate) mod launch; +pub(crate) mod terminal; diff --git a/crates/plannotator-tui/src/rex/terminal.rs b/crates/plannotator-tui/src/rex/terminal.rs new file mode 100644 index 0000000..d41395c --- /dev/null +++ b/crates/plannotator-tui/src/rex/terminal.rs @@ -0,0 +1,46 @@ +//! `plannotator-tui rex terminal`: review a block's recent output, read with `rex capture`. +//! `crate::terminal` turns the text into the review. + +use anyhow::Result; + +use super::agent::AGENT_VAR; +use super::command::{Rex, short_id}; +use crate::terminal::{clean, open, title}; + +/// The block's last `lines` lines of output, cleaned; an error when the block shows nothing. +/// Unwrapped lines are the program's own lines, so the split that narrows the block before +/// the read does not re-break them. +pub(crate) fn read(rex: &Rex, block: &str, lines: u32) -> Result { + let captured = rex.run(&["capture", "-b", block, "--unwrap"], None)?; + let text = tail(&clean(&captured), lines); + anyhow::ensure!(!text.is_empty(), "block {} has no output to review", short_id(block)); + Ok(text) +} + +/// The last `lines` lines of `text`. +fn tail(text: &str, lines: u32) -> String { + let all: Vec<&str> = text.lines().collect(); + let keep = usize::try_from(lines).unwrap_or(usize::MAX).min(all.len()); + all.get(all.len() - keep..).unwrap_or_default().join("\n") +} + +/// The split's entry point: read the block now and open the review. Feedback goes to the +/// agent the launcher found there, else the clipboard. +pub(crate) fn run(rex: &Rex, block: &str, lines: u32) -> Result<()> { + let text = read(rex, block, lines)?; + let agent = std::env::var(AGENT_VAR).ok().filter(|agent| !agent.is_empty()); + let title = title(short_id(block), agent.as_deref()); + crate::cli::run_ui(|width| open(&text, title, width, crate::cli::delivery(true))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_last_lines_are_kept() { + assert_eq!(tail("one\ntwo\nthree", 2), "two\nthree"); + assert_eq!(tail("one\ntwo", 200), "one\ntwo"); + assert_eq!(tail("", 5), ""); + } +} diff --git a/crates/plannotator-tui/src/terminal.rs b/crates/plannotator-tui/src/terminal.rs new file mode 100644 index 0000000..e5d9c26 --- /dev/null +++ b/crates/plannotator-tui/src/terminal.rs @@ -0,0 +1,232 @@ +//! A terminal's recent output as a review: cleaned of escapes and presented as one fenced +//! code block, so the output shows verbatim and a quote in the feedback is the text as it +//! appeared. The document is transient: nothing is written to disk. Each host reads the +//! output its own way (`herdr pane read`, `rex capture`) and hands the text here. + +use anyhow::Result; +use plannotator_tui_schema::{DocumentSource, Provenance}; +use unicode_width::UnicodeWidthChar; + +use crate::app::App; +use crate::delivery::Delivery; + +/// Recent lines read when `--lines` is not given. Herdr caps a read at 1000. +pub(crate) const DEFAULT_LINES: u32 = 200; + +/// Terminal text as plain lines: escape sequences and control characters removed, trailing +/// whitespace trimmed, and blank lines dropped from both ends. +pub(crate) fn clean(raw: &str) -> String { + let plain = strip_escapes(raw); + let lines: Vec<&str> = plain.lines().map(str::trim_end).collect(); + let first = lines.iter().position(|line| !line.is_empty()); + let last = lines.iter().rposition(|line| !line.is_empty()); + match (first, last) { + (Some(first), Some(last)) => lines.get(first..=last).unwrap_or_default().join("\n"), + _ => String::new(), + } +} + +/// Drop CSI, OSC, and two-byte escape sequences, and every control character but `\n` and +/// `\t`. `\r\n` becomes `\n`; a lone `\r` is dropped. +fn strip_escapes(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '\u{1b}' => match chars.next() { + // CSI: parameters and intermediates up to one final byte in `@`..=`~`. + Some('[') => { + for c in chars.by_ref() { + if ('@'..='~').contains(&c) { + break; + } + } + } + // OSC: up to BEL or ST (`ESC \`). + Some(']') => { + while let Some(c) = chars.next() { + if c == '\u{7}' || (c == '\u{1b}' && chars.next_if_eq(&'\\').is_some()) { + break; + } + } + } + _ => {} + }, + '\n' | '\t' => out.push(ch), + c if c.is_control() => {} + c => out.push(c), + } + } + out +} + +/// Output re-broken to fit a width, and where it was broken inside a token. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Wrapped { + pub(crate) text: String, + /// Byte offsets in `text` of the newlines that split a token wider than the view. A + /// quote across one rejoins without a space; a break at a space rejoins with one. + pub(crate) breaks: Vec, +} + +/// Break lines wider than `width` columns, at the last space that fits when there is one. +/// Code blocks clip rather than wrap, so without this a long line would be cut off. +pub(crate) fn wrap(text: &str, width: usize) -> Wrapped { + let width = width.max(1); + let mut out = String::with_capacity(text.len()); + let mut breaks = Vec::new(); + for (i, line) in text.lines().enumerate() { + if i > 0 { + out.push('\n'); + } + let mut rest = line; + while let Some(cut) = overflow(rest, width) { + let head = rest.get(..cut).unwrap_or(rest); + // A space inside the leading indentation is no place to break. + let indent = head.len() - head.trim_start_matches(' ').len(); + if let Some(space) = head.rfind(' ').filter(|&space| space > indent) { + out.push_str(head.get(..space).unwrap_or(head)); + rest = rest.get(space + 1..).unwrap_or_default(); + } else { + out.push_str(head); + breaks.push(out.len()); + rest = rest.get(cut..).unwrap_or_default(); + } + out.push('\n'); + } + out.push_str(rest); + } + Wrapped { text: out, breaks } +} + +/// The byte offset where `line` stops fitting in `width` columns, or `None` when it fits. +fn overflow(line: &str, width: usize) -> Option { + let mut used = 0; + for (offset, ch) in line.char_indices() { + used += UnicodeWidthChar::width(ch).unwrap_or(0); + // A character wider than the whole line still has to go somewhere. + if used > width && offset > 0 { + return Some(offset); + } + } + None +} + +/// The output as Markdown: one fenced `text` block, its fence longer than any backtick run +/// inside so the output cannot close it. +pub(crate) fn document(text: &str) -> String { + let longest = text.split(|c| c != '`').map(str::len).max().unwrap_or(0); + let fence = "`".repeat(longest.max(2) + 1); + format!("{fence}text\n{text}\n{fence}\n") +} + +/// What the header calls the review: `terminal · claude in w1:p2`, or the pane alone. +pub(crate) fn title(pane: &str, agent: Option<&str>) -> String { + match agent { + Some(agent) => format!("terminal · {agent} in {pane}"), + None => format!("terminal · {pane}"), + } +} + +/// The document the app opens: transient, so it gets no sidecar, history, or drafts. +pub(crate) fn source(text: &str, title: String) -> DocumentSource { + DocumentSource::new(document(text), title, true, Provenance::Stdin) +} + +/// The review of `text` at `width`: wrapped to fit, with the breaks the wrapper put inside +/// tokens handed to the app so quotes across them read as printed. +pub(crate) fn open(text: &str, title: String, width: usize, delivery: Box) -> Result { + let wrapped = wrap(text, width); + let source = source(&wrapped.text, title); + // The output starts after the opening fence line. + let start = source.content.find('\n').map_or(0, |newline| newline + 1); + let mut app = App::open(source, width, delivery)?; + app.set_terminal_breaks(wrapped.breaks.iter().map(|offset| start + offset).collect()); + Ok(app) +} + +#[cfg(test)] +#[allow(clippy::expect_used, reason = "tests assert by panicking")] +mod tests { + use super::*; + + #[test] + fn cleaning_removes_escapes_controls_and_surrounding_blank_lines() { + let raw = "\n \n\u{1b}[1;32m$ cargo test\u{1b}[0m \r\n\u{1b}]8;;file:///x\u{7}link\u{1b}]8;;\u{1b}\\ ok\u{7}\n\t indented\n\n\n"; + assert_eq!(clean(raw), "$ cargo test\nlink ok\n\t indented"); + } + + #[test] + fn output_with_nothing_visible_cleans_to_empty() { + assert_eq!(clean(""), ""); + assert_eq!(clean("\n \n\u{1b}[2J\n"), ""); + } + + #[test] + fn blank_lines_inside_the_output_are_kept() { + assert_eq!(clean("one\n\n\ntwo\n"), "one\n\n\ntwo"); + } + + #[test] + fn long_lines_break_at_a_space_and_short_lines_are_untouched() { + let text = |input: &str, width| wrap(input, width).text; + assert_eq!( + wrap("the quick brown fox", 10), + Wrapped { text: "the quick\nbrown fox".into(), breaks: vec![] } + ); + assert_eq!(text("short\n indented", 10), "short\n indented"); + assert_eq!(wrap("abcdefghijkl", 5), Wrapped { text: "abcde\nfghij\nkl".into(), breaks: vec![5, 11] }); + assert_eq!(text(" abcdefghi", 6), " abc\ndefghi"); + // Wide characters count as two columns. + assert_eq!(text("한글한글", 4), "한글\n한글"); + } + + #[test] + fn the_fence_outlasts_any_backticks_in_the_output() { + assert_eq!(document("ls"), "```text\nls\n```\n"); + let doc = document("echo ```` done"); + assert!(doc.starts_with("`````text\n") && doc.ends_with("\n`````\n"), "{doc}"); + } + + #[test] + fn the_document_renders_the_output_as_one_code_block() { + let doc = crate::doc::Document::parse(document("# not a heading\n* not a list")); + let kinds: Vec<_> = doc.blocks.iter().map(|block| block.kind).collect(); + assert_eq!(kinds, [crate::doc::BlockKind::CodeBlock]); + } + + #[test] + fn the_review_is_transient_and_titled_by_its_pane() { + let source = source("ok", title("w1:p2", Some("claude"))); + assert!(source.transient); + assert_eq!(source.name, "terminal · claude in w1:p2"); + assert_eq!(title("w1:p2", None), "terminal · w1:p2"); + } + + #[test] + fn a_quote_across_a_broken_path_reads_as_printed() { + let path = "/home/user/projects/herdr/src/deeply/nested/module/source.rs"; + assert_eq!(path.len(), 60); + let text = format!("error in {path}\nok"); + let wrapped = wrap(&text, 24); + // `error in` breaks at its space; the path breaks twice inside itself. + assert_eq!(wrapped.text.lines().count(), 5, "{}", wrapped.text); + assert_eq!(wrapped.breaks.len(), 2); + let across: Vec<&str> = wrapped.text.lines().take(4).collect(); + let mut app = + open(&text, title("w1:p1", None), 24, Box::new(crate::delivery::Discard)).expect("opens"); + app.add_quote_annotation_at( + &across.join("\n"), + 1, + plannotator_tui_schema::Kind::Comment, + "fix".into(), + ) + .expect("annotated"); + assert_eq!( + app.feedback(), + format!( + "# Annotations on terminal · w1:p1\n\n## Annotation 1\nComment on: \"error in {path}\"\n> fix\n\n" + ) + ); + } +} diff --git a/skills/plannotator-tui/SKILL.md b/skills/plannotator-tui/SKILL.md index 917f359..6f8abe8 100644 --- a/skills/plannotator-tui/SKILL.md +++ b/skills/plannotator-tui/SKILL.md @@ -1,12 +1,13 @@ --- name: plannotator-tui -description: Open a Markdown plan or document for the human to review and annotate in a Herdr pane; their feedback arrives as your next message. Use when you have written a plan, spec, or design document that needs human review before you act on it. Draft; not yet wired into an installer. +description: Open a Markdown plan or document for the human to review and annotate in a Herdr or Rex pane; their feedback arrives as your next message. Use when you have written a plan, spec, or design document that needs human review before you act on it. Draft; not yet wired into an installer. --- # plannotator-tui: hand a document to the human for review -Only when `HERDR_ENV=1` (you are running inside Herdr). Otherwise tell the human where the -file is and ask them to review it. +Only when `HERDR_ENV=1` (you are running inside Herdr) or `REX_BLOCK` is set (inside Rex). +Otherwise tell the human where the file is and ask them to review it. Inside Rex, use +`plannotator-tui rex open` in step 2 instead of `plannotator-tui herdr open`. 1. Write the document to a file. Do not paste it into chat as well. 2. Open it for review, from your own pane: From 0dc4658e4dd3bc32f85b2eddb18bf87974794a96 Mon Sep 17 00:00:00 2001 From: Kyle Wigley <9877221+kwigley@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:48:29 -0400 Subject: [PATCH 2/7] feat(rex): open the newest markdown file a pane names Add `plannotator-tui rex find`. It reads the pane's recent output, picks the newest .md path that exists on disk, and opens it in a split. --- README.md | 1 + crates/plannotator-tui/src/cli.rs | 1 + crates/plannotator-tui/src/rex/agent.rs | 136 +++++++++++++++++++--- crates/plannotator-tui/src/rex/cli.rs | 15 ++- crates/plannotator-tui/src/rex/command.rs | 17 +-- crates/plannotator-tui/src/rex/launch.rs | 96 ++++++++------- crates/plannotator-tui/src/rex/mod.rs | 4 +- crates/plannotator-tui/src/rex/paths.rs | 83 +++++++++++++ 8 files changed, 283 insertions(+), 70 deletions(-) create mode 100644 crates/plannotator-tui/src/rex/paths.rs diff --git a/README.md b/README.md index 207eadf..d257f18 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ Each launcher opens the split and exits: 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 `, else `REX_BLOCK`, so an agent that runs a launcher in its own diff --git a/crates/plannotator-tui/src/cli.rs b/crates/plannotator-tui/src/cli.rs index e2705de..aa05ecc 100644 --- a/crates/plannotator-tui/src/cli.rs +++ b/crates/plannotator-tui/src/cli.rs @@ -44,6 +44,7 @@ pub(crate) const USAGE: &str = "usage: plannotator-tui rex open [file.md | folder] [--block ] [--session ] [--paste] plannotator-tui rex last [--block ] [--session ] [--newest] [--paste] plannotator-tui rex terminal [--block ] [--session ] [--lines N] [--paste] [--print] + plannotator-tui rex find [--block ] [--session ] [--lines N] [--paste] plannotator-tui rex pane (open | last | terminal --block [--lines N]) plannotator-tui last [--host claude|codex|pi|omp|copilot|droid|hermes|opencode|cmd|agy] [--pid N] [--session ] [--session-id ] [--stdin] [--print] [--pick N] [--newest]"; diff --git a/crates/plannotator-tui/src/rex/agent.rs b/crates/plannotator-tui/src/rex/agent.rs index c5154e3..ea3e9d4a 100644 --- a/crates/plannotator-tui/src/rex/agent.rs +++ b/crates/plannotator-tui/src/rex/agent.rs @@ -3,18 +3,25 @@ //! Sending pastes the feedback into the block as a paste event, so Rex wraps it in the //! bracketed-paste markers the agent asked for, then presses Enter. In paste mode it leaves //! Enter to you: the feedback waits in the agent's prompt and the block takes focus, ready to -//! edit. Before writing anything it asks the block's `program_status` whether the agent is at -//! a dialog, the same refusal Herdr gives with `agent_blocked`. +//! edit. Before writing anything it checks that the agent's process still runs in the block, +//! so a block that fell back to a shell never receives the text, and asks the block's +//! `program_status` whether the agent is at a dialog, the same refusal Herdr gives with +//! `agent_blocked`. + +use std::cell::RefCell; use serde_json::{Value, json}; use super::command::{Rex, RexError, short_id}; +use super::launch::foreground; use crate::delivery::{Delivery, DeliveryError}; /// `PLANNOTATOR_TUI_REX_BLOCK`: the block to send feedback to, set by `plannotator-tui rex`. pub(crate) const BLOCK_VAR: &str = "PLANNOTATOR_TUI_REX_BLOCK"; /// `PLANNOTATOR_TUI_DELIVER_AGENT`: the agent's host label, for the footer and the archive. pub(crate) const AGENT_VAR: &str = "PLANNOTATOR_TUI_DELIVER_AGENT"; +/// `PLANNOTATOR_TUI_REX_PID`: the agent's process ID when the review opened. +pub(crate) const PID_VAR: &str = "PLANNOTATOR_TUI_REX_PID"; /// `PLANNOTATOR_TUI_REX_SUBMIT=0`: paste into the agent's prompt without pressing Enter. pub(crate) const SUBMIT_VAR: &str = "PLANNOTATOR_TUI_REX_SUBMIT"; @@ -23,8 +30,13 @@ pub(crate) struct RexAgent { rex: Rex, block: String, agent: Option, + /// The agent's process ID at launch. A send needs this process in the block's foreground. + pid: u32, /// Press Enter after the paste; `false` leaves the feedback in the prompt to edit. submit: bool, + /// Feedback that is in the agent's prompt but not yet submitted, because Enter failed. A + /// retry of the same feedback presses Enter again and does not paste it a second time. + pasted: RefCell>, } impl RexAgent { @@ -32,9 +44,10 @@ impl RexAgent { pub(crate) fn from_lookup(env: impl Fn(&str) -> Option) -> Option { let non_empty = |k: &str| env(k).filter(|v| !v.is_empty()); let block = non_empty(BLOCK_VAR)?; + let pid = non_empty(PID_VAR)?.parse().ok()?; let rex = Rex::from_lookup(&env, None).ok()?; let submit = env(SUBMIT_VAR).as_deref() != Some("0"); - Some(Self { rex, block, agent: non_empty(AGENT_VAR), submit }) + Some(Self { rex, block, agent: non_empty(AGENT_VAR), pid, submit, pasted: RefCell::default() }) } } @@ -54,18 +67,34 @@ impl Delivery for RexAgent { } fn deliver(&self, feedback: &str) -> Result<(), DeliveryError> { + let process = self.rex.terminal_method(&self.block, "process").map_err(unavailable)?; + if foreground(&process).map(|fg| fg.pid) != Some(self.pid) { + let who = self.agent.as_deref().unwrap_or("the agent"); + return Err(DeliveryError::Unavailable(format!("{who} no longer runs in {}", self.describe()))); + } let status = self.rex.terminal_method(&self.block, "program_status").map_err(unavailable)?; if let Some(reason) = blocked_reason(&status) { return Err(DeliveryError::Blocked(format!("{} is {reason}", self.describe()))); } - let paste = json!({ - "block_id": self.block, - "event": { "type": "paste", "payload": { "text": feedback } }, - }); - self.rex.api("block.event", &paste).map_err(unavailable)?; - let next: &[&str] = - if self.submit { &["send-key", "-b", &self.block, "enter"] } else { &["focus", &self.block] }; - self.rex.run(next, None).map_err(unavailable)?; + if self.pasted.borrow().as_deref() != Some(feedback) { + let paste = json!({ + "block_id": self.block, + "event": { "type": "paste", "payload": { "text": feedback } }, + }); + self.rex.api("block.event", &paste).map_err(unavailable)?; + } + if !self.submit { + // Focus only helps you edit the prompt. The feedback has already landed. + let _ = self.rex.run(&["focus", &self.block], None); + return Ok(()); + } + *self.pasted.borrow_mut() = Some(feedback.to_owned()); + self.rex.run(&["send-key", "-b", &self.block, "enter"], None).map_err(|err| { + DeliveryError::Failed(anyhow::anyhow!( + "the feedback is in the agent's prompt but Enter did not land ({err}); press Enter in its pane" + )) + })?; + self.pasted.take(); Ok(()) } } @@ -105,9 +134,11 @@ mod tests { fn only_a_named_block_inside_a_rex_session_is_a_target() { let block = "block:01a17dd5-e47c-754f-b72e-ef739ea1d6db"; assert!(agent(&[("REX_SESSION", "session:a")]).is_none()); - assert!(agent(&[(BLOCK_VAR, block)]).is_none()); - let target = agent(&[("REX_SESSION", "session:a"), (BLOCK_VAR, block), (AGENT_VAR, "claude")]) - .expect("a target"); + assert!(agent(&[(BLOCK_VAR, block), (PID_VAR, "7")]).is_none()); + assert!(agent(&[("REX_SESSION", "session:a"), (BLOCK_VAR, block)]).is_none()); + let target = + agent(&[("REX_SESSION", "session:a"), (BLOCK_VAR, block), (PID_VAR, "7"), (AGENT_VAR, "claude")]) + .expect("a target"); assert_eq!(target.describe(), "claude in a1d6db"); assert_eq!(target.agent_host(), Some("claude")); } @@ -116,8 +147,14 @@ mod tests { fn only_an_explicit_zero_turns_sending_into_pasting_and_the_target_says_so() { let block = "block:01a17dd5-e47c-754f-b72e-ef739ea1d6db"; let with = |submit: &'static str| { - agent(&[("REX_SESSION", "s"), (BLOCK_VAR, block), (AGENT_VAR, "claude"), (SUBMIT_VAR, submit)]) - .expect("a target") + agent(&[ + ("REX_SESSION", "s"), + (BLOCK_VAR, block), + (PID_VAR, "7"), + (AGENT_VAR, "claude"), + (SUBMIT_VAR, submit), + ]) + .expect("a target") }; assert!(with("1").submit && with("").submit); assert!(!with("0").submit); @@ -134,4 +171,71 @@ mod tests { let unknown = json!({"records":[{"state":"blocked","kind":"telepathy"}]}); assert_eq!(blocked_reason(&unknown).as_deref(), Some("waiting for you")); } + + /// A `rex` that logs its arguments and answers the calls a send makes. The foreground + /// process is pid 42; `enter-fails` in the directory makes `send-key` fail. + #[cfg(unix)] + fn fake_rex(tag: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt as _; + let dir = std::env::temp_dir().join(format!("plannotator-tui-rex-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let script = format!( + r#"#!/bin/sh +echo "$1 $2" >> {dir}/log +case "$1 $2" in + "do com.superlogical.terminal.process") echo '{{"foreground":{{"pid":42,"argv0":"claude"}}}}' ;; + "do com.superlogical.terminal.program_status") echo '{{"records":[]}}' ;; + "api call") cat > /dev/null; echo '{{}}' ;; + "send-key -b") if [ -f {dir}/enter-fails ]; then echo boom >&2; exit 1; fi ;; +esac +"#, + dir = dir.display() + ); + std::fs::write(dir.join("rex"), script).expect("write rex"); + std::fs::set_permissions(dir.join("rex"), std::fs::Permissions::from_mode(0o755)).expect("chmod"); + dir + } + + #[cfg(unix)] + fn sender(dir: &std::path::Path, pid: &str) -> RexAgent { + let bin = dir.display().to_string(); + agent(&[ + ("REX_BIN_DIR", &bin), + ("REX_SESSION", "session:a"), + (BLOCK_VAR, "block:b"), + (PID_VAR, pid), + (AGENT_VAR, "claude"), + ]) + .expect("a target") + } + + #[cfg(unix)] + fn pastes(dir: &std::path::Path) -> usize { + std::fs::read_to_string(dir.join("log")).expect("log").lines().filter(|l| *l == "api call").count() + } + + #[cfg(unix)] + #[test] + fn a_retry_after_a_failed_enter_presses_enter_and_does_not_paste_again() { + let dir = fake_rex("retry"); + let target = sender(&dir, "42"); + std::fs::write(dir.join("enter-fails"), "").expect("flag"); + assert!(matches!(target.deliver("notes"), Err(DeliveryError::Failed(_)))); + std::fs::remove_file(dir.join("enter-fails")).expect("flag"); + target.deliver("notes").expect("the retry sends"); + assert_eq!(pastes(&dir), 1); + target.deliver("notes").expect("a later send"); + assert_eq!(pastes(&dir), 2, "a send that landed starts over"); + std::fs::remove_dir_all(dir).ok(); + } + + #[cfg(unix)] + #[test] + fn nothing_is_written_once_the_agent_has_left_the_block() { + let dir = fake_rex("gone"); + let target = sender(&dir, "43"); + assert!(matches!(target.deliver("notes"), Err(DeliveryError::Unavailable(_)))); + assert_eq!(pastes(&dir), 0); + std::fs::remove_dir_all(dir).ok(); + } } diff --git a/crates/plannotator-tui/src/rex/cli.rs b/crates/plannotator-tui/src/rex/cli.rs index 93c44bf..61ef70d 100644 --- a/crates/plannotator-tui/src/rex/cli.rs +++ b/crates/plannotator-tui/src/rex/cli.rs @@ -8,10 +8,10 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use super::command::Rex; -use super::launch::{self, Delivery, Review}; +use super::launch::{self, Handoff, Review}; use crate::cli::{USAGE, interactive, last_command, lines_value, wait_on_error}; -/// `plannotator-tui rex open|last|terminal`: open a review in a split beside a block. The block +/// `plannotator-tui rex open|last|terminal|find`: open a review in a split beside a block. The block /// defaults to `REX_BLOCK`, so an agent running this from its own block reviews itself. /// `--paste` leaves the feedback in the agent's prompt instead of sending it. `rex pane` is /// what the split runs. @@ -20,21 +20,24 @@ pub(crate) fn run(args: &[String]) -> Result<()> { Some("last") => Review::Last { newest: false }, Some("open") => Review::Open(None), Some("terminal") => Review::Terminal { lines: crate::terminal::DEFAULT_LINES }, + Some("find") => Review::Find { lines: crate::terminal::DEFAULT_LINES }, Some("pane") => return pane(args.get(1..).unwrap_or_default()), _ => anyhow::bail!(USAGE), }; let mut block = None; let mut session = None; - let mut delivery = Delivery::Send; + let mut handoff = Handoff::Send; let mut print = false; let mut rest = args.get(1..).unwrap_or_default().iter(); while let Some(arg) = rest.next() { match (arg.as_str(), &mut review) { ("--block", _) => block = Some(rest.next().context("--block needs a value")?.clone()), ("--session", _) => session = Some(rest.next().context("--session needs a value")?.clone()), - ("--paste", _) => delivery = Delivery::Paste, + ("--paste", _) => handoff = Handoff::Paste, ("--newest", Review::Last { newest }) => *newest = true, - ("--lines", Review::Terminal { lines }) => *lines = lines_value(rest.next())?, + ("--lines", Review::Terminal { lines } | Review::Find { lines }) => { + *lines = lines_value(rest.next())?; + } ("--print", Review::Terminal { .. }) => print = true, (flag, _) if flag.starts_with("--") => anyhow::bail!("unknown flag {flag}\n{USAGE}"), (path, Review::Open(open @ None)) => *open = Some(std::path::absolute(path)?), @@ -49,7 +52,7 @@ pub(crate) fn run(args: &[String]) -> Result<()> { print!("{}", crate::terminal::document(&super::terminal::read(&rex, &block, *lines)?)); return Ok(()); } - launch::run(&rex, &block, &review, delivery) + launch::run(&rex, &block, &review, handoff) } /// The split's entry point. Rex closes the split when this exits, so an error waits for diff --git a/crates/plannotator-tui/src/rex/command.rs b/crates/plannotator-tui/src/rex/command.rs index 37a58a8..9450711 100644 --- a/crates/plannotator-tui/src/rex/command.rs +++ b/crates/plannotator-tui/src/rex/command.rs @@ -117,21 +117,22 @@ impl Rex { /// Call one of a terminal block's methods, such as `process` or `program_status`. pub(crate) fn terminal_method(&self, block: &str, method: &str) -> Result { let method = format!("com.superlogical.terminal.{method}"); - let stdout = self.run(&["do", &method, "-b", block], None)?; - serde_json::from_str(&stdout).map_err(|err| { - RexError::Failed(anyhow::anyhow!("{method} returned something other than JSON: {err}")) - }) + json(&method, &self.run(&["do", &method, "-b", block], None)?) } /// Call a server API method with `args`, sent on stdin so no text lands in `ps`. pub(crate) fn api(&self, method: &str, args: &Value) -> Result { - let stdout = self.run(&["api", "call", method, "-"], Some(&args.to_string()))?; - serde_json::from_str(&stdout).map_err(|err| { - RexError::Failed(anyhow::anyhow!("{method} returned something other than JSON: {err}")) - }) + json(method, &self.run(&["api", "call", method, "-"], Some(&args.to_string()))?) } } +/// What `method` printed, as JSON. +fn json(method: &str, stdout: &str) -> Result { + serde_json::from_str(stdout).map_err(|err| { + RexError::Failed(anyhow::anyhow!("{method} returned something other than JSON: {err}")) + }) +} + #[cfg(test)] #[allow(clippy::expect_used, reason = "tests assert by panicking")] mod tests { diff --git a/crates/plannotator-tui/src/rex/launch.rs b/crates/plannotator-tui/src/rex/launch.rs index 2b6b7bf..4d5d56d 100644 --- a/crates/plannotator-tui/src/rex/launch.rs +++ b/crates/plannotator-tui/src/rex/launch.rs @@ -1,14 +1,14 @@ -//! `plannotator-tui rex last|open|terminal`: find the agent in a Rex block and open -//! plannotator-tui in a split to its right, wired to send feedback back to the block. A split is an ordinary pane, -//! so it keeps Rex's frame and theme. `foreground`, `command` and `split` are pure; only `run` -//! touches Rex. +//! `plannotator-tui rex last|open|terminal|find`: find the agent in a Rex block and open +//! plannotator-tui in a split to its right, wired to send feedback back to the block. A split +//! is an ordinary pane, so it keeps Rex's frame and theme. `foreground`, `argv` and `split` +//! are pure; only `run` touches Rex. use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde_json::{Value, json}; -use super::agent::{AGENT_VAR, BLOCK_VAR, SUBMIT_VAR}; +use super::agent::{AGENT_VAR, BLOCK_VAR, PID_VAR, SUBMIT_VAR}; use super::command::Rex; use crate::herdr::launch::known_host; @@ -21,11 +21,14 @@ pub(crate) enum Review { Open(Option), /// The block's last `lines` lines of output. Terminal { lines: u32 }, + /// The newest Markdown file named in the block's last `lines` lines of output. `run` + /// turns it into an `Open`. + Find { lines: u32 }, } /// How feedback reaches the agent. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum Delivery { +pub(crate) enum Handoff { /// As its next message. Send, /// Into its prompt, for you to edit and submit. @@ -55,43 +58,44 @@ pub(crate) fn foreground(process: &Value) -> Option { } /// The argv the pane runs: `env` sets the delivery target, then `plannotator-tui rex pane` -/// opens the review. -/// Without an agent in the block, feedback goes to the clipboard. -pub(crate) fn command( +/// opens the review. Without an agent in the block, feedback goes to the clipboard. +pub(crate) fn argv( exe: &Path, block: &str, fg: &Foreground, review: &Review, - delivery: Delivery, + handoff: Handoff, ) -> Result> { - let mut argv = vec!["/usr/bin/env".to_owned()]; + let mut args = vec!["/usr/bin/env".to_owned()]; if let Some(host) = fg.host { - argv.push(format!("{BLOCK_VAR}={block}")); - argv.push(format!("{AGENT_VAR}={host}")); - if delivery == Delivery::Paste { - argv.push(format!("{SUBMIT_VAR}=0")); + args.push(format!("{BLOCK_VAR}={block}")); + args.push(format!("{AGENT_VAR}={host}")); + args.push(format!("{PID_VAR}={}", fg.pid)); + if handoff == Handoff::Paste { + args.push(format!("{SUBMIT_VAR}=0")); } } - argv.extend([exe.display().to_string(), "rex".to_owned(), "pane".to_owned()]); + args.extend([exe.display().to_string(), "rex".to_owned(), "pane".to_owned()]); match review { Review::Last { newest } => { let host = fg.host.context("no supported agent runs in this block")?; - argv.extend(["last".to_owned(), "--pid".to_owned(), fg.pid.to_string()]); - argv.extend(["--host".to_owned(), host.to_owned()]); + args.extend(["last".to_owned(), "--pid".to_owned(), fg.pid.to_string()]); + args.extend(["--host".to_owned(), host.to_owned()]); if *newest { - argv.push("--newest".to_owned()); + args.push("--newest".to_owned()); } } Review::Open(path) => { let path = path.clone().or_else(|| fg.cwd.clone()).context("no file or folder to open")?; - argv.extend(["open".to_owned(), path.display().to_string()]); + args.extend(["open".to_owned(), path.display().to_string()]); } Review::Terminal { lines } => { - argv.extend(["terminal", "--block", block, "--lines"].map(str::to_owned)); - argv.push(lines.to_string()); + args.extend(["terminal", "--block", block, "--lines"].map(str::to_owned)); + args.push(lines.to_string()); } + Review::Find { .. } => anyhow::bail!("a find review is resolved to an open before the split"), } - Ok(argv) + Ok(args) } /// The `session.new_split` arguments for `argv`, to the right of `block`, starting in `cwd`. @@ -111,13 +115,27 @@ pub(crate) fn split(block: &str, cwd: Option<&Path>, argv: &[String]) -> Value { }) } +/// The Markdown file a `Find` names: the newest one in the block's last `lines` lines. +fn newest_markdown(rex: &Rex, block: &str, fg: &Foreground, lines: u32) -> Result { + let text = super::terminal::read(rex, block, lines)?; + let home = std::env::var_os("HOME").map(PathBuf::from); + super::paths::markdown_files(&text, fg.cwd.as_deref(), home.as_deref()) + .into_iter() + .next() + .with_context(|| format!("no Markdown file named in the last {lines} lines of block {block}")) +} + /// Open the review in a split to the right of `block`. -pub(crate) fn run(rex: &Rex, block: &str, review: &Review, delivery: Delivery) -> Result<()> { +pub(crate) fn run(rex: &Rex, block: &str, review: &Review, handoff: Handoff) -> Result<()> { let process = rex.terminal_method(block, "process")?; let fg = foreground(&process).with_context(|| format!("block {block} has no foreground process"))?; let exe = std::env::current_exe().context("finding the plannotator-tui binary")?; - let argv = command(&exe, block, &fg, review, delivery)?; - rex.api("session.new_split", &split(block, fg.cwd.as_deref(), &argv))?; + let review = match review { + Review::Find { lines } => Review::Open(Some(newest_markdown(rex, block, &fg, *lines)?)), + other => other.clone(), + }; + let args = argv(&exe, block, &fg, &review, handoff)?; + rex.api("session.new_split", &split(block, fg.cwd.as_deref(), &args))?; Ok(()) } @@ -145,20 +163,21 @@ mod tests { #[test] fn last_reads_the_agents_pid_and_sends_back_to_its_block() { - let argv = command( + let args = argv( Path::new("/bin/pt"), "block:b", &parse(CLAUDE), &Review::Last { newest: true }, - Delivery::Send, + Handoff::Send, ) .expect("argv"); assert_eq!( - argv, + args, [ "/usr/bin/env", "PLANNOTATOR_TUI_REX_BLOCK=block:b", "PLANNOTATOR_TUI_DELIVER_AGENT=claude", + "PLANNOTATOR_TUI_REX_PID=60223", "/bin/pt", "rex", "pane", @@ -171,12 +190,12 @@ mod tests { ] ); assert!( - command( + argv( Path::new("/bin/pt"), "block:b", &parse(SHELL), &Review::Last { newest: false }, - Delivery::Send + Handoff::Send ) .is_err() ); @@ -184,16 +203,15 @@ mod tests { #[test] fn open_without_an_agent_reviews_the_blocks_folder_and_copies_to_the_clipboard() { - let argv = - command(Path::new("/bin/pt"), "block:b", &parse(SHELL), &Review::Open(None), Delivery::Send) - .expect("argv"); - assert_eq!(argv, ["/usr/bin/env", "/bin/pt", "rex", "pane", "open", "/Users/me"]); + let args = argv(Path::new("/bin/pt"), "block:b", &parse(SHELL), &Review::Open(None), Handoff::Send) + .expect("argv"); + assert_eq!(args, ["/usr/bin/env", "/bin/pt", "rex", "pane", "open", "/Users/me"]); } #[test] fn pasting_is_passed_on_only_where_there_is_an_agent_to_paste_into() { let paste = |json| { - command(Path::new("/bin/pt"), "block:b", &parse(json), &Review::Open(None), Delivery::Paste) + argv(Path::new("/bin/pt"), "block:b", &parse(json), &Review::Open(None), Handoff::Paste) .expect("argv") }; assert!(paste(CLAUDE).contains(&"PLANNOTATOR_TUI_REX_SUBMIT=0".to_owned())); @@ -202,16 +220,16 @@ mod tests { #[test] fn a_terminal_review_reads_the_block_from_inside_the_split() { - let argv = command( + let args = argv( Path::new("/bin/pt"), "block:b", &parse(SHELL), &Review::Terminal { lines: 80 }, - Delivery::Send, + Handoff::Send, ) .expect("argv"); assert_eq!( - argv, + args, ["/usr/bin/env", "/bin/pt", "rex", "pane", "terminal", "--block", "block:b", "--lines", "80"] ); } diff --git a/crates/plannotator-tui/src/rex/mod.rs b/crates/plannotator-tui/src/rex/mod.rs index a95a7c5..3ab3121 100644 --- a/crates/plannotator-tui/src/rex/mod.rs +++ b/crates/plannotator-tui/src/rex/mod.rs @@ -2,10 +2,12 @@ //! send the feedback back to that block. //! //! Rex is driven only through its `rex` CLI. `command` runs it; `launch` opens the split; -//! `terminal` reads a block's output; `agent` is the delivery target the opened app sends to. +//! `terminal` reads a block's output; `paths` finds the Markdown files in it; `agent` is the +//! delivery target the opened app sends to; `cli` parses the `rex` subcommands. pub(crate) mod agent; pub(crate) mod cli; pub(crate) mod command; pub(crate) mod launch; +pub(crate) mod paths; pub(crate) mod terminal; diff --git a/crates/plannotator-tui/src/rex/paths.rs b/crates/plannotator-tui/src/rex/paths.rs new file mode 100644 index 0000000..d0a2a18 --- /dev/null +++ b/crates/plannotator-tui/src/rex/paths.rs @@ -0,0 +1,83 @@ +//! `plannotator-tui rex find`: the Markdown files a block's output names, newest first. + +use std::path::{Path, PathBuf}; + +/// Characters that end a path in prose: whitespace, quotes, brackets and the markup around a +/// link or an inline-code span. +fn is_boundary(c: char) -> bool { + c.is_whitespace() || matches!(c, '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '<' | '>' | ',' | '|' | '*') +} + +/// The existing `.md` files `text` names, newest mention first and each once. A relative path +/// or one starting with `~/` resolves against `cwd` or `home`; a path that is not a file is +/// dropped, so a sentence that ends in `notes.md.` or a path from another machine never wins. +pub(crate) fn markdown_files(text: &str, cwd: Option<&Path>, home: Option<&Path>) -> Vec { + let mut found: Vec = Vec::new(); + for token in text.split(is_boundary).rev() { + let token = token.trim_end_matches(['.', ':', ';', '!', '?']); + if !Path::new(token).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("md")) { + continue; + } + let path = match (token.strip_prefix("~/"), home) { + (Some(rest), Some(home)) => home.join(rest), + (Some(_), None) => continue, + (None, _) => PathBuf::from(token), + }; + let path = match cwd { + Some(cwd) if path.is_relative() => cwd.join(path), + _ => path, + }; + if path.is_file() && !found.contains(&path) { + found.push(path); + } + } + found +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing, reason = "tests assert by panicking")] +mod tests { + use super::*; + + struct Scratch(PathBuf); + + impl Scratch { + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).ok(); + } + } + + fn scratch(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("plannotator-tui-paths-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + Scratch(dir) + } + + #[test] + fn the_newest_existing_file_comes_first_and_prose_punctuation_is_dropped() { + let dir = scratch("order"); + for name in ["old.md", "new.md"] { + std::fs::write(dir.path().join(name), "# x").expect("write"); + } + let root = dir.path().display(); + let text = + format!("see {root}/old.md, then `{root}/new.md`.\nnot here: {root}/gone.md and {root}/old.md"); + let found = markdown_files(&text, None, None); + assert_eq!(found, vec![dir.path().join("old.md"), dir.path().join("new.md")]); + } + + #[test] + fn relative_and_home_paths_resolve_against_the_blocks_folder_and_home() { + let dir = scratch("relative"); + std::fs::create_dir(dir.path().join("docs")).expect("mkdir"); + std::fs::write(dir.path().join("docs/plan.md"), "# x").expect("write"); + let found = markdown_files("docs/plan.md ~/docs/plan.md", Some(dir.path()), Some(dir.path())); + assert_eq!(found, vec![dir.path().join("docs/plan.md")]); + } +} From 6c43cdda08965ef46278fa805325b3b9346cc969 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 29 Oct 2026 12:08:38 -0400 Subject: [PATCH 3/7] fix(rex): send only to an agent that has reported its state to rex An empty program_status said "not blocked", so a send pasted and pressed Enter into whatever was on screen. Claude Code reports nothing at its startup trust prompt, where Enter picks "No, exit": the agent quit and the review still said "sent". A done or idle record left by an earlier process passed the check too. A send now needs a status record owned by the agent's process, else it is unavailable and the review copies instead, like Herdr's agent_not_ready. --paste presses no Enter, so it still pastes. --- README.md | 4 +- crates/plannotator-tui/src/rex/agent.rs | 70 ++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d257f18..99d579b 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,9 @@ The pane is `--block `, else `REX_BLOCK`, so an agent that runs a launcher i 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`. A send waits while Rex reports the agent blocked on -a permission prompt or a question, as it does in Herdr. Rex does not report its theme to +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 diff --git a/crates/plannotator-tui/src/rex/agent.rs b/crates/plannotator-tui/src/rex/agent.rs index ea3e9d4a..653fdc4 100644 --- a/crates/plannotator-tui/src/rex/agent.rs +++ b/crates/plannotator-tui/src/rex/agent.rs @@ -6,7 +6,8 @@ //! edit. Before writing anything it checks that the agent's process still runs in the block, //! so a block that fell back to a shell never receives the text, and asks the block's //! `program_status` whether the agent is at a dialog, the same refusal Herdr gives with -//! `agent_blocked`. +//! `agent_blocked`. A send also needs the agent to have reported its state there at all: an +//! agent that has not could be at a dialog Rex cannot see. use std::cell::RefCell; @@ -76,6 +77,15 @@ impl Delivery for RexAgent { if let Some(reason) = blocked_reason(&status) { return Err(DeliveryError::Blocked(format!("{} is {reason}", self.describe()))); } + // Without a report from the agent, Rex cannot say it is not at a dialog, and Enter + // there would answer it. Herdr refuses an agent it cannot read the same way. + if self.submit && !reports_state(&status, self.pid) { + let who = self.agent.as_deref().unwrap_or("the agent"); + return Err(DeliveryError::Unavailable(format!( + "{who} in {} has not reported its state to Rex", + short_id(&self.block) + ))); + } if self.pasted.borrow().as_deref() != Some(feedback) { let paste = json!({ "block_id": self.block, @@ -119,6 +129,15 @@ pub(crate) fn blocked_reason(status: &Value) -> Option { }) } +/// Whether the agent, process `pid`, has reported its state in `program_status` JSON. Rex +/// keeps a `done` record after its program exits, so only a record the agent owns counts. +pub(crate) fn reports_state(status: &Value, pid: u32) -> bool { + let records = status.get("records").and_then(Value::as_array); + records.is_some_and(|records| { + records.iter().any(|r| r.pointer("/owner/pid").and_then(Value::as_u64) == Some(u64::from(pid))) + }) +} + #[cfg(test)] #[allow(clippy::expect_used, reason = "tests assert by panicking")] mod tests { @@ -172,8 +191,21 @@ mod tests { assert_eq!(blocked_reason(&unknown).as_deref(), Some("waiting for you")); } + #[test] + fn only_a_record_the_agent_owns_says_it_has_reported_its_state() { + assert!(!reports_state(&json!({"records":[]}), 42)); + assert!(!reports_state(&json!({}), 42)); + let mine = json!({"records":[{"state":"idle","owner":{"pid":42,"name":"2.1.312"}}]}); + assert!(reports_state(&mine, 42)); + // A `done` record outlives its program; one left by an earlier process says nothing. + let earlier = json!({"records":[{"state":"done","owner":{"pid":41,"name":"2.1.312"}}]}); + assert!(!reports_state(&earlier, 42)); + assert!(!reports_state(&json!({"records":[{"state":"idle"}]}), 42)); + } + /// A `rex` that logs its arguments and answers the calls a send makes. The foreground - /// process is pid 42; `enter-fails` in the directory makes `send-key` fail. + /// process is pid 42 and reports itself idle; `silent` in the directory makes it report + /// nothing, and `enter-fails` makes `send-key` fail. #[cfg(unix)] fn fake_rex(tag: &str) -> std::path::PathBuf { use std::os::unix::fs::PermissionsExt as _; @@ -184,7 +216,9 @@ mod tests { echo "$1 $2" >> {dir}/log case "$1 $2" in "do com.superlogical.terminal.process") echo '{{"foreground":{{"pid":42,"argv0":"claude"}}}}' ;; - "do com.superlogical.terminal.program_status") echo '{{"records":[]}}' ;; + "do com.superlogical.terminal.program_status") + if [ -f {dir}/silent ]; then echo '{{"records":[]}}' + else echo '{{"records":[{{"state":"idle","owner":{{"pid":42,"name":"claude"}}}}]}}'; fi ;; "api call") cat > /dev/null; echo '{{}}' ;; "send-key -b") if [ -f {dir}/enter-fails ]; then echo boom >&2; exit 1; fi ;; esac @@ -198,15 +232,21 @@ esac #[cfg(unix)] fn sender(dir: &std::path::Path, pid: &str) -> RexAgent { + sender_with(dir, pid, &[]) + } + + #[cfg(unix)] + fn sender_with(dir: &std::path::Path, pid: &str, extra: &[(&str, &str)]) -> RexAgent { let bin = dir.display().to_string(); - agent(&[ - ("REX_BIN_DIR", &bin), + let mut vars = vec![ + ("REX_BIN_DIR", bin.as_str()), ("REX_SESSION", "session:a"), (BLOCK_VAR, "block:b"), (PID_VAR, pid), (AGENT_VAR, "claude"), - ]) - .expect("a target") + ]; + vars.extend_from_slice(extra); + agent(&vars).expect("a target") } #[cfg(unix)] @@ -229,6 +269,22 @@ esac std::fs::remove_dir_all(dir).ok(); } + #[cfg(unix)] + #[test] + fn an_agent_that_has_not_reported_its_state_gets_no_enter_but_can_take_a_paste() { + let dir = fake_rex("silent"); + std::fs::write(dir.join("silent"), "").expect("flag"); + let target = sender(&dir, "42"); + assert!(matches!(target.deliver("notes"), Err(DeliveryError::Unavailable(_)))); + assert_eq!(pastes(&dir), 0); + let paste = sender_with(&dir, "42", &[(SUBMIT_VAR, "0")]); + paste.deliver("notes").expect("a paste leaves Enter to you"); + assert_eq!(pastes(&dir), 1); + let log = std::fs::read_to_string(dir.join("log")).expect("log"); + assert!(!log.lines().any(|l| l.starts_with("send-key")), "no Enter: {log}"); + std::fs::remove_dir_all(dir).ok(); + } + #[cfg(unix)] #[test] fn nothing_is_written_once_the_agent_has_left_the_block() { From f7db29139f121728a945030e02a5809222363a67 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 29 Oct 2026 12:10:35 -0400 Subject: [PATCH 4/7] fix(rex): count a send as delivered once the paste lands When Enter failed after the paste, the send failed and the notes stayed pending. A retry of the same text pressed Enter only, but any change in between (one more note) pasted everything again on top of the text already waiting in the prompt, and the agent got the first notes twice. Once the paste lands the feedback is in the agent's prompt, so the send counts: the block takes focus and the status line says to press Enter there. Delivery gains a notice for that; Herdr leaves it empty. --- .../src/app/review_test_support.rs | 7 +- crates/plannotator-tui/src/app/send.rs | 3 + crates/plannotator-tui/src/app/send/tests.rs | 16 +++++ crates/plannotator-tui/src/delivery.rs | 5 ++ crates/plannotator-tui/src/rex/agent.rs | 65 ++++++++++--------- 5 files changed, 65 insertions(+), 31 deletions(-) diff --git a/crates/plannotator-tui/src/app/review_test_support.rs b/crates/plannotator-tui/src/app/review_test_support.rs index ea59206..427b3d0 100644 --- a/crates/plannotator-tui/src/app/review_test_support.rs +++ b/crates/plannotator-tui/src/app/review_test_support.rs @@ -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, @@ -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 { + matches!(self.outcome.get(), Outcome::Noticed).then(|| "press Enter there".to_owned()) + } } pub(super) fn file_app(tag: &str) -> (PathBuf, App, RecordingDelivery) { diff --git a/crates/plannotator-tui/src/app/send.rs b/crates/plannotator-tui/src/app/send.rs index 84fd8a8..9de39ad 100644 --- a/crates/plannotator-tui/src/app/send.rs +++ b/crates/plannotator-tui/src/app/send.rs @@ -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, diff --git a/crates/plannotator-tui/src/app/send/tests.rs b/crates/plannotator-tui/src/app/send/tests.rs index 08aefd9..23f6988 100644 --- a/crates/plannotator-tui/src/app/send/tests.rs +++ b/crates/plannotator-tui/src/app/send/tests.rs @@ -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"); diff --git a/crates/plannotator-tui/src/delivery.rs b/crates/plannotator-tui/src/delivery.rs index b43674b..664fd5f 100644 --- a/crates/plannotator-tui/src/delivery.rs +++ b/crates/plannotator-tui/src/delivery.rs @@ -47,6 +47,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 { + None + } } /// The OSC 52 sequence that hands `text` to the terminal's clipboard. diff --git a/crates/plannotator-tui/src/rex/agent.rs b/crates/plannotator-tui/src/rex/agent.rs index 653fdc4..4d9d68b 100644 --- a/crates/plannotator-tui/src/rex/agent.rs +++ b/crates/plannotator-tui/src/rex/agent.rs @@ -1,7 +1,9 @@ //! An agent running in a Rex block, as a delivery target. //! //! Sending pastes the feedback into the block as a paste event, so Rex wraps it in the -//! bracketed-paste markers the agent asked for, then presses Enter. In paste mode it leaves +//! bracketed-paste markers the agent asked for, then presses Enter. Once the paste lands the +//! feedback is the agent's: if Enter fails, it waits in the prompt, the block takes focus, and +//! the send still counts, so nothing pastes it twice. In paste mode it leaves //! Enter to you: the feedback waits in the agent's prompt and the block takes focus, ready to //! edit. Before writing anything it checks that the agent's process still runs in the block, //! so a block that fell back to a shell never receives the text, and asks the block's @@ -35,9 +37,8 @@ pub(crate) struct RexAgent { pid: u32, /// Press Enter after the paste; `false` leaves the feedback in the prompt to edit. submit: bool, - /// Feedback that is in the agent's prompt but not yet submitted, because Enter failed. A - /// retry of the same feedback presses Enter again and does not paste it a second time. - pasted: RefCell>, + /// What the last send left for you to do: Enter, when it did not reach the agent. + notice: RefCell>, } impl RexAgent { @@ -48,7 +49,7 @@ impl RexAgent { let pid = non_empty(PID_VAR)?.parse().ok()?; let rex = Rex::from_lookup(&env, None).ok()?; let submit = env(SUBMIT_VAR).as_deref() != Some("0"); - Some(Self { rex, block, agent: non_empty(AGENT_VAR), pid, submit, pasted: RefCell::default() }) + Some(Self { rex, block, agent: non_empty(AGENT_VAR), pid, submit, notice: RefCell::default() }) } } @@ -86,27 +87,28 @@ impl Delivery for RexAgent { short_id(&self.block) ))); } - if self.pasted.borrow().as_deref() != Some(feedback) { - let paste = json!({ - "block_id": self.block, - "event": { "type": "paste", "payload": { "text": feedback } }, - }); - self.rex.api("block.event", &paste).map_err(unavailable)?; + self.notice.take(); + let paste = json!({ + "block_id": self.block, + "event": { "type": "paste", "payload": { "text": feedback } }, + }); + self.rex.api("block.event", &paste).map_err(unavailable)?; + // The feedback has landed in the agent's prompt. A failed Enter leaves it there, sent + // but unsubmitted: a failure here would invite a second paste on top of it. + if self.submit { + if self.rex.run(&["send-key", "-b", &self.block, "enter"], None).is_ok() { + return Ok(()); + } + *self.notice.borrow_mut() = Some("Enter did not reach it; press Enter in its pane".to_owned()); } - if !self.submit { - // Focus only helps you edit the prompt. The feedback has already landed. - let _ = self.rex.run(&["focus", &self.block], None); - return Ok(()); - } - *self.pasted.borrow_mut() = Some(feedback.to_owned()); - self.rex.run(&["send-key", "-b", &self.block, "enter"], None).map_err(|err| { - DeliveryError::Failed(anyhow::anyhow!( - "the feedback is in the agent's prompt but Enter did not land ({err}); press Enter in its pane" - )) - })?; - self.pasted.take(); + // Focus only helps you edit or submit the prompt. + let _ = self.rex.run(&["focus", &self.block], None); Ok(()) } + + fn notice(&self) -> Option { + self.notice.take() + } } fn unavailable(err: RexError) -> DeliveryError { @@ -256,16 +258,19 @@ esac #[cfg(unix)] #[test] - fn a_retry_after_a_failed_enter_presses_enter_and_does_not_paste_again() { - let dir = fake_rex("retry"); + fn a_failed_enter_after_the_paste_still_counts_as_sent_and_says_so() { + let dir = fake_rex("enter"); let target = sender(&dir, "42"); std::fs::write(dir.join("enter-fails"), "").expect("flag"); - assert!(matches!(target.deliver("notes"), Err(DeliveryError::Failed(_)))); + target.deliver("notes").expect("the paste landed, so the send counts"); + assert_eq!(target.notice().as_deref(), Some("Enter did not reach it; press Enter in its pane")); + assert_eq!(target.notice(), None, "a notice is shown once"); + let log = std::fs::read_to_string(dir.join("log")).expect("log"); + assert!(log.lines().any(|l| l == "focus block:b"), "the block takes focus: {log}"); std::fs::remove_file(dir.join("enter-fails")).expect("flag"); - target.deliver("notes").expect("the retry sends"); - assert_eq!(pastes(&dir), 1); - target.deliver("notes").expect("a later send"); - assert_eq!(pastes(&dir), 2, "a send that landed starts over"); + target.deliver("more notes").expect("a later send"); + assert_eq!(target.notice(), None); + assert_eq!(pastes(&dir), 2, "each send pastes once"); std::fs::remove_dir_all(dir).ok(); } From 11ac8b7e26a328a3389b96615141cae6a0d635e6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 29 Oct 2026 12:11:10 -0400 Subject: [PATCH 5/7] fix(rex): name the block once when the agent has left it The refusal read "claude no longer runs in claude in 4eb9f1". --- crates/plannotator-tui/src/rex/agent.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/plannotator-tui/src/rex/agent.rs b/crates/plannotator-tui/src/rex/agent.rs index 4d9d68b..a56d79d 100644 --- a/crates/plannotator-tui/src/rex/agent.rs +++ b/crates/plannotator-tui/src/rex/agent.rs @@ -72,7 +72,10 @@ impl Delivery for RexAgent { let process = self.rex.terminal_method(&self.block, "process").map_err(unavailable)?; if foreground(&process).map(|fg| fg.pid) != Some(self.pid) { let who = self.agent.as_deref().unwrap_or("the agent"); - return Err(DeliveryError::Unavailable(format!("{who} no longer runs in {}", self.describe()))); + return Err(DeliveryError::Unavailable(format!( + "{who} no longer runs in {}", + short_id(&self.block) + ))); } let status = self.rex.terminal_method(&self.block, "program_status").map_err(unavailable)?; if let Some(reason) = blocked_reason(&status) { @@ -295,7 +298,9 @@ esac fn nothing_is_written_once_the_agent_has_left_the_block() { let dir = fake_rex("gone"); let target = sender(&dir, "43"); - assert!(matches!(target.deliver("notes"), Err(DeliveryError::Unavailable(_)))); + let err = target.deliver("notes").expect_err("refused"); + let named_once = |m: &str| m.starts_with("claude no longer runs in ") && !m.contains("claude in"); + assert!(matches!(&err, DeliveryError::Unavailable(m) if named_once(m)), "{err}"); assert_eq!(pastes(&dir), 0); std::fs::remove_dir_all(dir).ok(); } From 000e82705033b68125147e2a5a4341802e6012de Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 29 Oct 2026 12:13:28 -0400 Subject: [PATCH 6/7] feat(rex): wire --gate and --result-file through rex open SKILL.md tells agents to open a plan with --gate for a sign-off, but `rex open` rejected the flag. It now takes --gate and --result-file like `herdr open`: the split shows Approve, the decision reaches the agent's block, and the result file records it. A gated `rex open` was also caught by the direct gate, which read `rex` as the file to review. --- crates/plannotator-tui/src/cli.rs | 12 ++-- crates/plannotator-tui/src/rex/cli.rs | 79 +++++++++++++++++++++--- crates/plannotator-tui/src/rex/launch.rs | 41 +++++++++--- 3 files changed, 112 insertions(+), 20 deletions(-) diff --git a/crates/plannotator-tui/src/cli.rs b/crates/plannotator-tui/src/cli.rs index aa05ecc..0db90ab 100644 --- a/crates/plannotator-tui/src/cli.rs +++ b/crates/plannotator-tui/src/cli.rs @@ -42,10 +42,12 @@ pub(crate) const USAGE: &str = "usage: plannotator-tui herdr terminal [--lines N] [--placement P] [--deliver-to ] [--print] plannotator-tui herdr pane plannotator-tui rex open [file.md | folder] [--block ] [--session ] [--paste] + [--gate] [--result-file ] plannotator-tui rex last [--block ] [--session ] [--newest] [--paste] plannotator-tui rex terminal [--block ] [--session ] [--lines N] [--paste] [--print] plannotator-tui rex find [--block ] [--session ] [--lines N] [--paste] - plannotator-tui rex pane (open | last | terminal --block [--lines N]) + plannotator-tui rex pane (open [--gate [--result-file ]] | last + | terminal --block [--lines N]) plannotator-tui last [--host claude|codex|pi|omp|copilot|droid|hermes|opencode|cmd|agy] [--pid N] [--session ] [--session-id ] [--stdin] [--print] [--pick N] [--newest]"; @@ -145,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), @@ -441,9 +445,9 @@ pub(crate) 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)?; } diff --git a/crates/plannotator-tui/src/rex/cli.rs b/crates/plannotator-tui/src/rex/cli.rs index 61ef70d..e29a4e6 100644 --- a/crates/plannotator-tui/src/rex/cli.rs +++ b/crates/plannotator-tui/src/rex/cli.rs @@ -8,17 +8,24 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use super::command::Rex; -use super::launch::{self, Handoff, Review}; -use crate::cli::{USAGE, interactive, last_command, lines_value, wait_on_error}; +use super::launch::{self, Gate, Handoff, Review}; +use crate::cli::{USAGE, gated_pane, interactive, last_command, lines_value, wait_on_error}; +use crate::gate::{self, GateError}; /// `plannotator-tui rex open|last|terminal|find`: open a review in a split beside a block. The block /// defaults to `REX_BLOCK`, so an agent running this from its own block reviews itself. -/// `--paste` leaves the feedback in the agent's prompt instead of sending it. `rex pane` is -/// what the split runs. +/// `--paste` leaves the feedback in the agent's prompt instead of sending it; `open --gate` +/// adds Approve, as `herdr open --gate` does. `rex pane` is what the split runs. A gated open +/// with a result file fails as a [`GateError`], like the direct gate. pub(crate) fn run(args: &[String]) -> Result<()> { + let strict = args.first().is_some_and(|sub| sub == "open") && gate::is_strict(args); + launch_review(args).map_err(|err| if strict { GateError(err).into() } else { err }) +} + +fn launch_review(args: &[String]) -> Result<()> { let mut review = match args.first().map(String::as_str) { Some("last") => Review::Last { newest: false }, - Some("open") => Review::Open(None), + Some("open") => Review::Open(None, None), Some("terminal") => Review::Terminal { lines: crate::terminal::DEFAULT_LINES }, Some("find") => Review::Find { lines: crate::terminal::DEFAULT_LINES }, Some("pane") => return pane(args.get(1..).unwrap_or_default()), @@ -28,6 +35,8 @@ pub(crate) fn run(args: &[String]) -> Result<()> { let mut session = None; let mut handoff = Handoff::Send; let mut print = false; + let mut gated = false; + let mut result_file = None; let mut rest = args.get(1..).unwrap_or_default().iter(); while let Some(arg) = rest.next() { match (arg.as_str(), &mut review) { @@ -39,11 +48,27 @@ pub(crate) fn run(args: &[String]) -> Result<()> { *lines = lines_value(rest.next())?; } ("--print", Review::Terminal { .. }) => print = true, + ("--gate", Review::Open(..)) => gated = true, + ("--result-file", Review::Open(..)) => { + let value = rest.next().filter(|v| !v.starts_with("--")); + let value = value.context("missing value for --result-file")?; + result_file = Some(crate::workspace_paths::absolute(std::path::Path::new(value))); + } + ("--gate" | "--result-file", _) => anyhow::bail!("{arg} is only for `rex open`"), (flag, _) if flag.starts_with("--") => anyhow::bail!("unknown flag {flag}\n{USAGE}"), - (path, Review::Open(open @ None)) => *open = Some(std::path::absolute(path)?), + (path, Review::Open(open @ None, _)) => *open = Some(std::path::absolute(path)?), (extra, _) => anyhow::bail!("unexpected argument {extra:?}\n{USAGE}"), } } + if let Some(file) = &result_file { + anyhow::ensure!(gated, "--result-file requires --gate"); + // The pane publishes when the review ends; a name that is already taken would only + // fail then, after the review. + gate::check_result_file(file)?; + } + if let (true, Review::Open(_, gate)) = (gated, &mut review) { + *gate = Some(Gate { result_file }); + } let block = block .or_else(|| std::env::var("REX_BLOCK").ok().filter(|b| !b.is_empty())) .context("no Rex block: pass --block or run inside Rex (REX_BLOCK is not set)")?; @@ -60,9 +85,15 @@ pub(crate) fn run(args: &[String]) -> Result<()> { fn pane(args: &[String]) -> Result<()> { let result = match args.first().map(String::as_str) { Some("last") => last_command(args.get(1..).unwrap_or_default()), - Some("open") => match args.get(1..) { - Some([path]) => interactive(&PathBuf::from(path)), - _ => Err(anyhow::anyhow!("rex pane open takes one path\n{USAGE}")), + Some("open") => match args.get(1..).unwrap_or_default() { + [path] => interactive(&PathBuf::from(path)), + [path, gate] if gate == "--gate" => gated_pane(&PathBuf::from(path), None), + [path, gate, flag, file] if gate == "--gate" && flag == "--result-file" => { + gated_pane(&PathBuf::from(path), Some(std::path::Path::new(file))) + } + _ => Err(anyhow::anyhow!( + "rex pane open takes one path, then --gate [--result-file ]\n{USAGE}" + )), }, Some("terminal") => pane_terminal(args.get(1..).unwrap_or_default()), _ => Err(anyhow::anyhow!(USAGE)), @@ -88,3 +119,33 @@ fn pane_terminal(args: &[String]) -> Result<()> { let rex = Rex::from_lookup(|k| std::env::var(k).ok(), None)?; super::terminal::run(&rex, &block, lines) } + +#[cfg(test)] +#[allow(clippy::expect_used, reason = "tests assert by panicking")] +mod tests { + use super::*; + + fn args(list: &[&str]) -> Vec { + list.iter().map(|a| (*a).to_owned()).collect() + } + + #[test] + fn the_gate_belongs_to_open_and_a_result_file_needs_it() { + let err = |list: &[&str]| run(&args(list)).map(drop).err().map(|e| e.to_string()).unwrap_or_default(); + assert_eq!(err(&["last", "--gate"]), "--gate is only for `rex open`"); + assert_eq!( + err(&["terminal", "--result-file", "/tmp/x.json"]), + "--result-file is only for `rex open`" + ); + assert_eq!(err(&["open", "--result-file", "/tmp/x.json"]), "--result-file requires --gate"); + let strict = run(&args(&["open", "--result-file", "/tmp/x.json"])).expect_err("refused"); + assert!(strict.is::(), "a result file makes every failure a gate error"); + } + + #[test] + fn a_gated_rex_open_is_a_launch_not_a_direct_review_of_a_file_named_rex() { + let launched = crate::cli::run(&args(&["rex", "open", "--result-file", "/tmp/x.json"])); + let err = launched.expect_err("refused").to_string(); + assert_eq!(err, "--result-file requires --gate"); + } +} diff --git a/crates/plannotator-tui/src/rex/launch.rs b/crates/plannotator-tui/src/rex/launch.rs index 4d5d56d..08a28d3 100644 --- a/crates/plannotator-tui/src/rex/launch.rs +++ b/crates/plannotator-tui/src/rex/launch.rs @@ -17,8 +17,9 @@ use crate::herdr::launch::known_host; pub(crate) enum Review { /// The agent's recent replies; `newest` skips the picker. Last { newest: bool }, - /// A file or folder; the block's directory when `None`. - Open(Option), + /// A file or folder; the block's directory when `None`. With a [`Gate`], the review can + /// be approved, as `herdr open --gate` does. + Open(Option, Option), /// The block's last `lines` lines of output. Terminal { lines: u32 }, /// The newest Markdown file named in the block's last `lines` lines of output. `run` @@ -26,6 +27,12 @@ pub(crate) enum Review { Find { lines: u32 }, } +/// `rex open --gate`: Approve beside Send, and where the decision is published. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct Gate { + pub(crate) result_file: Option, +} + /// How feedback reaches the agent. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Handoff { @@ -85,9 +92,15 @@ pub(crate) fn argv( args.push("--newest".to_owned()); } } - Review::Open(path) => { + Review::Open(path, gate) => { let path = path.clone().or_else(|| fg.cwd.clone()).context("no file or folder to open")?; args.extend(["open".to_owned(), path.display().to_string()]); + if let Some(gate) = gate { + args.push("--gate".to_owned()); + if let Some(file) = &gate.result_file { + args.extend(["--result-file".to_owned(), file.display().to_string()]); + } + } } Review::Terminal { lines } => { args.extend(["terminal", "--block", block, "--lines"].map(str::to_owned)); @@ -131,7 +144,7 @@ pub(crate) fn run(rex: &Rex, block: &str, review: &Review, handoff: Handoff) -> let fg = foreground(&process).with_context(|| format!("block {block} has no foreground process"))?; let exe = std::env::current_exe().context("finding the plannotator-tui binary")?; let review = match review { - Review::Find { lines } => Review::Open(Some(newest_markdown(rex, block, &fg, *lines)?)), + Review::Find { lines } => Review::Open(Some(newest_markdown(rex, block, &fg, *lines)?), None), other => other.clone(), }; let args = argv(&exe, block, &fg, &review, handoff)?; @@ -203,21 +216,35 @@ mod tests { #[test] fn open_without_an_agent_reviews_the_blocks_folder_and_copies_to_the_clipboard() { - let args = argv(Path::new("/bin/pt"), "block:b", &parse(SHELL), &Review::Open(None), Handoff::Send) - .expect("argv"); + let args = + argv(Path::new("/bin/pt"), "block:b", &parse(SHELL), &Review::Open(None, None), Handoff::Send) + .expect("argv"); assert_eq!(args, ["/usr/bin/env", "/bin/pt", "rex", "pane", "open", "/Users/me"]); } #[test] fn pasting_is_passed_on_only_where_there_is_an_agent_to_paste_into() { let paste = |json| { - argv(Path::new("/bin/pt"), "block:b", &parse(json), &Review::Open(None), Handoff::Paste) + argv(Path::new("/bin/pt"), "block:b", &parse(json), &Review::Open(None, None), Handoff::Paste) .expect("argv") }; assert!(paste(CLAUDE).contains(&"PLANNOTATOR_TUI_REX_SUBMIT=0".to_owned())); assert!(!paste(SHELL).iter().any(|arg| arg.starts_with("PLANNOTATOR_TUI_REX"))); } + #[test] + fn a_gated_open_hands_the_gate_and_its_result_file_to_the_pane() { + let gated = |gate| { + let review = Review::Open(Some(PathBuf::from("/p/plan.md")), Some(gate)); + argv(Path::new("/bin/pt"), "block:b", &parse(CLAUDE), &review, Handoff::Send).expect("argv") + }; + let tail = |args: &[String]| args.iter().skip_while(|a| *a != "open").cloned().collect::>(); + assert_eq!(tail(&gated(Gate::default())), ["open", "/p/plan.md", "--gate"]); + let published = gated(Gate { result_file: Some(PathBuf::from("/p/decision.json")) }); + assert_eq!(tail(&published), ["open", "/p/plan.md", "--gate", "--result-file", "/p/decision.json"]); + assert!(published.contains(&"PLANNOTATOR_TUI_REX_BLOCK=block:b".to_owned()), "the agent hears it"); + } + #[test] fn a_terminal_review_reads_the_block_from_inside_the_split() { let args = argv( From 816f6776e08ed6dae91f3d647f1c466855f7369b Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 29 Oct 2026 12:13:55 -0400 Subject: [PATCH 7/7] docs(rex): place src/rex/ in the crate, name rex in delivery, and document --gate AGENTS.md listed src/rex/ as a repo-root path, the delivery module doc described Herdr only, and SKILL.md's --gate step had no Rex form. --- AGENTS.md | 4 ++-- README.md | 3 ++- crates/plannotator-tui/src/delivery.rs | 9 +++++---- skills/plannotator-tui/SKILL.md | 3 ++- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 29c881f..e70d64a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +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. -src/rex/ the Rex launchers (`plannotator-tui rex …`) and delivery, driven - through the `rex` CLI. + 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 diff --git a/README.md b/README.md index 99d579b..1cbf135 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,8 @@ plannotator-tui rex find [--lines N] # the newest .md file the pane's out The pane is `--block `, 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`. A send waits while Rex reports the agent blocked on +the header says `claude prompt in a1d6db`. `rex open --gate [--result-file ]` 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 diff --git a/crates/plannotator-tui/src/delivery.rs b/crates/plannotator-tui/src/delivery.rs index 664fd5f..554cd2c 100644 --- a/crates/plannotator-tui/src/delivery.rs +++ b/crates/plannotator-tui/src/delivery.rs @@ -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; @@ -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), diff --git a/skills/plannotator-tui/SKILL.md b/skills/plannotator-tui/SKILL.md index 6f8abe8..011faa3 100644 --- a/skills/plannotator-tui/SKILL.md +++ b/skills/plannotator-tui/SKILL.md @@ -7,7 +7,8 @@ description: Open a Markdown plan or document for the human to review and annota Only when `HERDR_ENV=1` (you are running inside Herdr) or `REX_BLOCK` is set (inside Rex). Otherwise tell the human where the file is and ask them to review it. Inside Rex, use -`plannotator-tui rex open` in step 2 instead of `plannotator-tui herdr open`. +`plannotator-tui rex open` wherever this says `plannotator-tui herdr open`; `--gate` works the +same. 1. Write the document to a file. Do not paste it into chat as well. 2. Open it for review, from your own pane: