diff --git a/AGENTS.md b/AGENTS.md index 3bf4a96..e70d64a 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. + 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 d4f139f..1cbf135 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,32 @@ pane. Mirror does not preserve overlay presentation, and Herdr 0.8.2 opens an overlay in its server's active tab, which can differ from the tab you are viewing. An explicit `--placement` or `PLANNOTATOR_TUI_PLACEMENT` still takes precedence. +## Inside Rex + +Bind keys or actions in Rex to the launchers below. Each opens plannotator-tui in a split beside +the focused pane: the agent's recent replies, its newest reply, the pane's folder, or the pane's +recent output. The header button sends the review to the agent in that pane as its next message: +`Send 3 new ▸ claude in a1d6db (E)`. A pane with no agent copies instead. + +Each launcher opens the split and exits: + +```sh +plannotator-tui rex last [--newest] # the agent's replies +plannotator-tui rex open [file.md | folder] # a document, or the pane's folder +plannotator-tui rex terminal [--lines N] # the pane's recent output; --print writes it out +plannotator-tui rex find [--lines N] # the newest .md file the pane's output names +``` + +The pane is `--block `, else `REX_BLOCK`, so an agent that runs a launcher in its own +shell reviews itself; that is how it hands you a plan. `--paste` puts the feedback in the +agent's prompt without pressing Enter and focuses the agent's pane, so you can edit it first; +the header says `claude prompt in a1d6db`. `rex open --gate [--result-file ]` adds +Approve for a sign-off, as `herdr open --gate` does. A send waits while Rex reports the agent blocked on +a permission prompt or a question, as it does in Herdr. It also needs the agent to report its +state to Rex (Claude Code and Codex do): until it has, the review copies instead, since Enter +could answer a dialog Rex cannot see, and `--paste` still works. Rex does not report its theme to +programs, so with a light Rex theme set `[ui] theme = "light"`. + ## Agent replies `plannotator-tui last` finds the transcript of the agent that launched your shell and shows a 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/cli.rs b/crates/plannotator-tui/src/cli.rs index 6f2e73f..0db90ab 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,13 @@ 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] + [--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 [--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]"; @@ -54,21 +62,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 => { @@ -135,7 +147,9 @@ fn wants_decision(args: &[String]) -> bool { let decision_flag = |arg: &String| gate::FLAGS.contains(&arg.as_str()); match args.first() { Some(first) if decision_flag(first) => true, - Some(first) if first.starts_with('-') || matches!(first.as_str(), "config" | "herdr" | "last") => { + Some(first) + if first.starts_with('-') || matches!(first.as_str(), "config" | "herdr" | "rex" | "last") => + { false } Some(_) => args.iter().skip(1).any(decision_flag), @@ -189,6 +203,7 @@ fn tool(args: &[String]) -> Result<()> { } Some("config") => show_config(), Some("herdr") => herdr_command(args.get(1..).unwrap_or_default()), + Some("rex") => crate::rex::cli::run(args.get(1..).unwrap_or_default()), Some("last") => last_command(args.get(1..).unwrap_or_default()), Some(flag) if flag.starts_with("--") => anyhow::bail!("unknown flag {flag}\n{USAGE}"), Some(_) => interactive(&path(0)?), @@ -333,18 +348,12 @@ fn herdr_terminal(args: &[String]) -> Result<()> { use crate::herdr::launch::{OpenArgs, TerminalRead, plan_terminal, run}; use crate::herdr::terminal; let mut open = OpenArgs::default(); - let mut lines = terminal::DEFAULT_LINES; + let mut lines = crate::terminal::DEFAULT_LINES; let mut print = false; let mut rest = args.iter(); while let Some(arg) = rest.next() { match arg.as_str() { - "--lines" => { - lines = rest - .next() - .and_then(|n| n.parse::().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 +369,7 @@ fn herdr_terminal(args: &[String]) -> Result<()> { let pane = env.reviewed_pane().context("no focused pane to read")?; let text = terminal::read(&env, &pane, lines)?; if print { - print!("{}", terminal::document(&text)); + print!("{}", crate::terminal::document(&text)); return Ok(()); } let config = Config::load()?; @@ -390,19 +399,29 @@ fn herdr_pane() -> Result<()> { }; // The pane closes when we exit; an error that flashes by is an error nobody can read. if let Err(err) = &result { - #[allow(clippy::print_stderr, reason = "the pane is the only place this can be read")] - { - eprintln!("plannotator-tui: {err:#}\n\npress enter to close"); - } - let mut line = String::new(); - let _ = std::io::stdin().read_line(&mut line); + wait_on_error(err); } result } +/// `--lines`: a count from 1. +pub(crate) fn lines_value(value: Option<&String>) -> Result { + 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(); @@ -426,9 +445,9 @@ fn last_command(args: &[String]) -> Result<()> { crate::last::run(&options) } -/// A gated review in a Herdr pane: the agent hears the decision through delivery, and the +/// A gated review in a Herdr or Rex pane: the agent hears the decision through delivery, and the /// result file, when asked for, records it. The pane has no caller reading its stdout. -fn gated_pane(path: &PathBuf, result_file: Option<&Path>) -> Result<()> { +pub(crate) fn gated_pane(path: &PathBuf, result_file: Option<&Path>) -> Result<()> { if let Some(file) = result_file { gate::check_result_file(file)?; } @@ -444,7 +463,7 @@ fn gated_pane(path: &PathBuf, result_file: Option<&Path>) -> Result<()> { } } -fn interactive(path: &PathBuf) -> Result<()> { +pub(crate) fn interactive(path: &PathBuf) -> Result<()> { run_ui(|width| open_app(path, width, true)) } diff --git a/crates/plannotator-tui/src/delivery.rs b/crates/plannotator-tui/src/delivery.rs index b43674b..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), @@ -47,6 +48,11 @@ pub(crate) trait Delivery { None } fn deliver(&self, feedback: &str) -> Result<(), DeliveryError>; + /// What the person still has to do after the last `deliver` succeeded, shown with its + /// status: the feedback reached the agent but did not finish, e.g. it waits unsubmitted. + fn notice(&self) -> Option { + None + } } /// The OSC 52 sequence that hands `text` to the terminal's clipboard. 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..a56d79d --- /dev/null +++ b/crates/plannotator-tui/src/rex/agent.rs @@ -0,0 +1,307 @@ +//! 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. 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 +//! `program_status` whether the agent is at a dialog, the same refusal Herdr gives with +//! `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; + +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"; + +#[derive(Debug)] +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, + /// What the last send left for you to do: Enter, when it did not reach the agent. + notice: RefCell>, +} + +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 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, notice: RefCell::default() }) + } +} + +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 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 {}", + short_id(&self.block) + ))); + } + 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()))); + } + // 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) + ))); + } + 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()); + } + // 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 { + 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(), + }) +} + +/// 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 { + 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), (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")); + } + + #[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), + (PID_VAR, "7"), + (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")); + } + + #[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 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 _; + 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") + 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 +"#, + 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 { + 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(); + let mut vars = vec![ + ("REX_BIN_DIR", bin.as_str()), + ("REX_SESSION", "session:a"), + (BLOCK_VAR, "block:b"), + (PID_VAR, pid), + (AGENT_VAR, "claude"), + ]; + vars.extend_from_slice(extra); + agent(&vars).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_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"); + 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("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(); + } + + #[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() { + let dir = fake_rex("gone"); + let target = sender(&dir, "43"); + 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(); + } +} diff --git a/crates/plannotator-tui/src/rex/cli.rs b/crates/plannotator-tui/src/rex/cli.rs new file mode 100644 index 0000000..e29a4e6 --- /dev/null +++ b/crates/plannotator-tui/src/rex/cli.rs @@ -0,0 +1,151 @@ +//! `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, 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; `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, 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 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) { + ("--block", _) => block = Some(rest.next().context("--block needs a value")?.clone()), + ("--session", _) => session = Some(rest.next().context("--session needs a value")?.clone()), + ("--paste", _) => handoff = Handoff::Paste, + ("--newest", Review::Last { newest }) => *newest = true, + ("--lines", Review::Terminal { lines } | Review::Find { lines }) => { + *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)?), + (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)")?; + 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, handoff) +} + +/// 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..).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)), + }; + 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) +} + +#[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/command.rs b/crates/plannotator-tui/src/rex/command.rs new file mode 100644 index 0000000..9450711 --- /dev/null +++ b/crates/plannotator-tui/src/rex/command.rs @@ -0,0 +1,171 @@ +//! 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}"); + 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 { + 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 { + 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..08a28d3 --- /dev/null +++ b/crates/plannotator-tui/src/rex/launch.rs @@ -0,0 +1,273 @@ +//! `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, PID_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`. 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` + /// turns it into an `Open`. + 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 { + /// 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 argv( + exe: &Path, + block: &str, + fg: &Foreground, + review: &Review, + handoff: Handoff, +) -> Result> { + let mut args = vec!["/usr/bin/env".to_owned()]; + if let Some(host) = fg.host { + 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")); + } + } + 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")?; + args.extend(["last".to_owned(), "--pid".to_owned(), fg.pid.to_string()]); + args.extend(["--host".to_owned(), host.to_owned()]); + if *newest { + args.push("--newest".to_owned()); + } + } + 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)); + args.push(lines.to_string()); + } + Review::Find { .. } => anyhow::bail!("a find review is resolved to an open before the split"), + } + Ok(args) +} + +/// 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 } }, + }) +} + +/// 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, 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 review = match review { + Review::Find { lines } => Review::Open(Some(newest_markdown(rex, block, &fg, *lines)?), None), + other => other.clone(), + }; + let args = argv(&exe, block, &fg, &review, handoff)?; + rex.api("session.new_split", &split(block, fg.cwd.as_deref(), &args))?; + 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 args = argv( + Path::new("/bin/pt"), + "block:b", + &parse(CLAUDE), + &Review::Last { newest: true }, + Handoff::Send, + ) + .expect("argv"); + assert_eq!( + args, + [ + "/usr/bin/env", + "PLANNOTATOR_TUI_REX_BLOCK=block:b", + "PLANNOTATOR_TUI_DELIVER_AGENT=claude", + "PLANNOTATOR_TUI_REX_PID=60223", + "/bin/pt", + "rex", + "pane", + "last", + "--pid", + "60223", + "--host", + "claude", + "--newest" + ] + ); + assert!( + argv( + Path::new("/bin/pt"), + "block:b", + &parse(SHELL), + &Review::Last { newest: false }, + Handoff::Send + ) + .is_err() + ); + } + + #[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, 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, 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( + Path::new("/bin/pt"), + "block:b", + &parse(SHELL), + &Review::Terminal { lines: 80 }, + Handoff::Send, + ) + .expect("argv"); + assert_eq!( + args, + ["/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..3ab3121 --- /dev/null +++ b/crates/plannotator-tui/src/rex/mod.rs @@ -0,0 +1,13 @@ +//! 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; `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")]); + } +} 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..011faa3 100644 --- a/skills/plannotator-tui/SKILL.md +++ b/skills/plannotator-tui/SKILL.md @@ -1,12 +1,14 @@ --- 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` 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: