From c1e64d4ae251410c74ef4120ea0a52b18b884f15 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 24 Sep 2026 17:05:43 -0700 Subject: [PATCH] feat(herdr): review a pane's recent terminal output refs plannotator/herdr-annotate#62 --- README.md | 7 + crates/plannotator-tui/README.md | 3 +- crates/plannotator-tui/src/app/feedback.rs | 14 +- crates/plannotator-tui/src/app/mod.rs | 9 + crates/plannotator-tui/src/cli.rs | 51 +++- crates/plannotator-tui/src/export.rs | 48 +++- crates/plannotator-tui/src/herdr/context.rs | 45 +++ crates/plannotator-tui/src/herdr/launch.rs | 40 ++- .../plannotator-tui/src/herdr/launch/tests.rs | 45 +++ crates/plannotator-tui/src/herdr/mod.rs | 1 + crates/plannotator-tui/src/herdr/terminal.rs | 264 ++++++++++++++++++ .../tests/support/fake-herdr.rs | 10 +- crates/plannotator-tui/tests/terminal.rs | 172 ++++++++++++ docs/spec-herdr-integration.md | 24 +- herdr/herdr-plugin.toml | 7 + herdr/test-manifest.py | 1 + 16 files changed, 717 insertions(+), 24 deletions(-) create mode 100644 crates/plannotator-tui/src/herdr/terminal.rs create mode 100644 crates/plannotator-tui/tests/terminal.rs diff --git a/README.md b/README.md index c2d3295..893991e 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,12 @@ placement = "overlay" # overlay (full tab, default) | split | popup `plannotator-tui herdr last --newest` opens the agent's newest reply without asking which one; without the flag the picker comes first, as it always has. +`plannotator-tui herdr terminal [--lines N]` opens the focused pane's recent output (the last +200 lines by default; Herdr caps a read at 1000) as a transient review, shown verbatim in one +code block. Send goes to the agent in that pane, as it does for a reply; a pane with no agent +copies instead. `--print` writes the document to stdout instead of opening a pane. Herdr +Annotate ships it as the `annotate.terminal` action with no default key. + `plannotator-tui config` prints the file's path and the values in effect. The `herdr/` directory in this repo is the development manifest; users should install Herdr Annotate. @@ -189,6 +195,7 @@ plannotator-tui --export # all active not plannotator-tui --annotate [comment|looks_good|delete] [--occurrence N] plannotator-tui --snapshot [cols rows scroll] [quote] # one frame as text plannotator-tui --bench # parse / layout timings +plannotator-tui herdr terminal [--lines N] --print # a Herdr pane's recent output as a document ``` ## Repository diff --git a/crates/plannotator-tui/README.md b/crates/plannotator-tui/README.md index 90fa742..5e9e50f 100644 --- a/crates/plannotator-tui/README.md +++ b/crates/plannotator-tui/README.md @@ -43,7 +43,7 @@ Every annotation is saved the moment it is made, as JSON, in the Plannotator dat sha256 of the path), so one file maps to one directory in both tools. The records are in the Plannotator Workspaces wire shape (`plannotator-tui-schema`); any agent can read them. Nothing is written next to your files. `PLANNOTATOR_DATA_DIR` relocates the directory. Transient -documents (an agent's last message, stdin) are never persisted. +documents (an agent's last message, a pane's terminal output, stdin) are never persisted. ## Headless tools @@ -54,6 +54,7 @@ plannotator-tui --blocks # block index, kind, firs plannotator-tui --annotate [comment|looks_good|delete] [--occurrence N] plannotator-tui --annotate-block plannotator-tui --snapshot [cols rows scroll] [select-quote] # one frame as text + mark map +plannotator-tui herdr terminal [--lines N] --print # the focused Herdr pane's recent output, as the review document ``` ## Measured (Apple Silicon, release build) diff --git a/crates/plannotator-tui/src/app/feedback.rs b/crates/plannotator-tui/src/app/feedback.rs index 24a34e9..8bb60c2 100644 --- a/crates/plannotator-tui/src/app/feedback.rs +++ b/crates/plannotator-tui/src/app/feedback.rs @@ -53,6 +53,8 @@ pub(super) struct Feedback { pub(super) parts: Vec, pub(super) annotations: Vec, pub(super) counts: HashMap, + /// Set for a terminal review: no line labels, and quotes rejoin these breaks. + pub(super) terminal_breaks: Option>, } impl Feedback { @@ -74,8 +76,12 @@ impl Feedback { .filter(|p| scope == SendScope::All || store.is_pending(p.annotation)) .map(|p| export::Entry { annotation: p.annotation, - lines: export::line_span(&doc.source, p.range), - range: p.range.clone(), + lines: self.terminal_breaks.is_none().then(|| export::line_span(&doc.source, p.range)), + quote: export::quote( + &doc.source, + p.range, + self.terminal_breaks.as_deref().unwrap_or_default(), + ), }) .collect(); if entries.is_empty() { @@ -84,7 +90,7 @@ impl Feedback { if !self.text.is_empty() { self.text.push('\n'); } - self.text.push_str(&export::feedback(&doc.source, name, &entries)); + self.text.push_str(&export::feedback(name, &entries)); let ids = entries.iter().map(|e| e.annotation.id.clone()).collect(); self.count += entries.len(); self.annotations.extend(entries.iter().map(|entry| { @@ -243,7 +249,7 @@ impl App { Provenance::File { path } => Some(path.clone()), _ => None, }; - let mut feedback = Feedback::default(); + let mut feedback = Feedback { terminal_breaks: self.terminal_breaks.clone(), ..Feedback::default() }; feedback.add(path, None, &self.open.source.name, &self.open.doc, self.open.store.clone(), scope); feedback } diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index bcc0962..7115697 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -178,6 +178,9 @@ pub(crate) struct App { message_transcript: String, /// The host-assigned session id, for the archive's `session`; never a path. message_session: Option, + /// Set for a terminal review: the offsets of line breaks the wrapper inserted inside a + /// token. Feedback quotes rejoin them and carry no line labels. + terminal_breaks: Option>, compose: Compose, /// Whether the terminal reports Shift+Enter distinctly (kitty keyboard protocol). pub(super) shift_enter: bool, @@ -245,6 +248,7 @@ impl App { message_host: String::new(), message_transcript: String::new(), message_session: None, + terminal_breaks: None, compose: Compose::default(), shift_enter: false, last_click: None, @@ -374,6 +378,11 @@ impl App { self.status = Some(status); } + /// Mark this as a terminal review whose wrapper broke tokens at `breaks` (sorted). + pub(crate) fn set_terminal_breaks(&mut self, breaks: Vec) { + self.terminal_breaks = Some(breaks); + } + pub(crate) fn record_frame(&mut self, ms: f64) { self.frame_ms = if self.frame_ms == 0.0 { ms } else { self.frame_ms * 0.9 + ms * 0.1 }; self.frame_max_ms = self.frame_max_ms.max(ms); diff --git a/crates/plannotator-tui/src/cli.rs b/crates/plannotator-tui/src/cli.rs index 512d76a..e4c3e78 100644 --- a/crates/plannotator-tui/src/cli.rs +++ b/crates/plannotator-tui/src/cli.rs @@ -35,6 +35,7 @@ const USAGE: &str = "usage: plannotator-tui --version plannotator-tui herdr open [file.md | folder] [--placement overlay|split|popup] [--deliver-to ] 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 last [--host claude|codex|pi|omp|copilot|droid|hermes|opencode] [--pid N] [--session ] [--session-id ] [--stdin] [--print] [--pick N] [--newest]"; @@ -180,6 +181,9 @@ fn herdr_command(args: &[String]) -> Result<()> { if sub == Some("pane") { return herdr_pane(); } + if sub == Some("terminal") { + return herdr_terminal(args.get(1..).unwrap_or_default()); + } if !matches!(sub, Some("open" | "last")) { anyhow::bail!(USAGE); } @@ -222,10 +226,55 @@ fn herdr_command(args: &[String]) -> Result<()> { run(&env, &launch) } +/// `plannotator-tui herdr terminal [--lines N] [--placement P] [--deliver-to PANE] [--print]`: +/// review the focused pane's recent output. The read happens here first so a pane with +/// nothing to show fails before a review pane opens; `--print` stops after it. +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 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")?; + } + "--placement" => { + let value = rest.next().context("--placement needs a value")?; + open.placement = Some(value.parse()?); + } + "--deliver-to" => { + open.deliver_to = Some(rest.next().context("--deliver-to needs a value")?.clone()); + } + "--print" => print = true, + other => anyhow::bail!("unexpected argument {other:?}\n{USAGE}"), + } + } + let env = HerdrEnv::from_env(); + let pane = env.reviewed_pane().context("no focused pane to read")?; + let text = terminal::read(&env, &pane, lines)?; + if print { + print!("{}", terminal::document(&text)); + return Ok(()); + } + let config = Config::load()?; + let cwd = std::env::current_dir().context("current directory")?; + let launch = plan_terminal(&env, &config, open, &cwd, TerminalRead { pane, lines })?; + run(&env, &launch) +} + /// The pane entrypoint: Herdr runs this in the opened pane; the environment says what to show. fn herdr_pane() -> Result<()> { let env = HerdrEnv::from_env(); - let result = if env.has_message_source() { + let result = if let Some(pane) = env.terminal_pane.clone() { + crate::herdr::terminal::run(&env, &pane) + } else if env.has_message_source() { crate::last::run(&crate::last::LastOptions { host: env.host.clone(), pid: env.message_pid, diff --git a/crates/plannotator-tui/src/export.rs b/crates/plannotator-tui/src/export.rs index 60710e3..5ee6f8d 100644 --- a/crates/plannotator-tui/src/export.rs +++ b/crates/plannotator-tui/src/export.rs @@ -12,23 +12,26 @@ use plannotator_tui_schema::{Annotation, Kind}; /// One annotation placed in the document, as the exporter needs it. pub(crate) struct Entry<'a> { pub(crate) annotation: &'a Annotation, - pub(crate) range: Range, - /// 1-based source line span of the annotated range. - pub(crate) lines: (usize, usize), + /// The annotated source text, as the agent should read it. + pub(crate) quote: String, + /// 1-based source line span of the annotated range; `None` where source lines mean + /// nothing to the reader (a terminal review), and the heading omits them. + pub(crate) lines: Option<(usize, usize)>, } -pub(crate) fn feedback(source: &str, name: &str, entries: &[Entry<'_>]) -> String { +pub(crate) fn feedback(name: &str, entries: &[Entry<'_>]) -> String { if entries.is_empty() { return "No annotations.".to_owned(); } let mut out = format!("# Annotations on {name}\n\n"); for (i, entry) in entries.iter().enumerate() { - let quoted = source.get(entry.range.clone()).unwrap_or(""); + let quoted = entry.quote.as_str(); let line_label = match entry.lines { - (a, b) if a == b => format!("line {a}"), - (a, b) => format!("lines {a}\u{2013}{b}"), + Some((a, b)) if a == b => format!(" (line {a})"), + Some((a, b)) => format!(" (lines {a}\u{2013}{b})"), + None => String::new(), }; - let _ = writeln!(out, "## Annotation {} ({line_label})", i + 1); + let _ = writeln!(out, "## Annotation {}{line_label}", i + 1); let body = entry.annotation.body.trim(); match entry.annotation.anchor.kind() { Kind::Delete => { @@ -71,6 +74,16 @@ fn quote_lines(text: &str) -> String { text.replace('\n', "\n> ") } +/// The source text under `range`, less the line breaks at `joins`: breaks a wrapper +/// inserted inside a token, which were never in the text the reader saw printed. +pub(crate) fn quote(source: &str, range: &Range, joins: &[usize]) -> String { + let text = source.get(range.clone()).unwrap_or(""); + text.char_indices() + .filter(|(offset, _)| joins.binary_search(&(range.start + offset)).is_err()) + .map(|(_, ch)| ch) + .collect() +} + /// 1-based line numbers of the first and last byte of `range`. pub(crate) fn line_span(source: &str, range: &Range) -> (usize, usize) { let line_at = |offset: usize| source.get(..offset).map_or(1, |s| s.matches('\n').count() + 1); @@ -78,7 +91,7 @@ pub(crate) fn line_span(source: &str, range: &Range) -> (usize, usize) { } #[cfg(test)] -#[allow(clippy::expect_used, reason = "tests assert by panicking")] +#[allow(clippy::expect_used, clippy::indexing_slicing, reason = "tests assert by panicking")] mod tests { use super::*; use plannotator_tui_schema::{Anchor, SourceRange, State}; @@ -110,10 +123,10 @@ mod tests { let (comment, r1) = annotation(source, "login page", Kind::Comment, "Which page?\nBe specific."); let (delete, r2) = annotation(source, "Drop the `legacy` path.", Kind::Delete, ""); let entries = [ - Entry { annotation: &comment, lines: line_span(source, &r1), range: r1 }, - Entry { annotation: &delete, lines: line_span(source, &r2), range: r2 }, + Entry { annotation: &comment, lines: Some(line_span(source, &r1)), quote: source[r1].to_owned() }, + Entry { annotation: &delete, lines: Some(line_span(source, &r2)), quote: source[r2].to_owned() }, ]; - let out = feedback(source, "plan.md", &entries); + let out = feedback("plan.md", &entries); assert_eq!( out, "# Annotations on plan.md\n\n\ @@ -122,6 +135,17 @@ mod tests { ); } + #[test] + fn an_entry_without_lines_has_no_line_label() { + let source = "$ ls\n"; + let (comment, range) = annotation(source, "ls", Kind::Comment, "why"); + let entries = [Entry { annotation: &comment, lines: None, quote: source[range].to_owned() }]; + assert_eq!( + feedback("terminal · w1:p1", &entries), + "# Annotations on terminal · w1:p1\n\n## Annotation 1\nComment on: \"ls\"\n> why\n\n" + ); + } + #[test] fn fences_grow_past_embedded_backticks() { assert!(fenced("has ``` inside").starts_with("````\n")); diff --git a/crates/plannotator-tui/src/herdr/context.rs b/crates/plannotator-tui/src/herdr/context.rs index 6abac4e..cfd70a5 100644 --- a/crates/plannotator-tui/src/herdr/context.rs +++ b/crates/plannotator-tui/src/herdr/context.rs @@ -57,6 +57,10 @@ pub(crate) struct HerdrEnv { pub(crate) session_id: Option, /// `PLANNOTATOR_TUI_NEWEST=1`: open the newest reply straight away, no picker. pub(crate) newest: bool, + /// `PLANNOTATOR_TUI_TERMINAL_PANE`: open this pane's recent output instead of a file. + pub(crate) terminal_pane: Option, + /// `PLANNOTATOR_TUI_TERMINAL_LINES`: how many recent lines to read from that pane. + pub(crate) terminal_lines: Option, } impl HerdrEnv { @@ -83,6 +87,8 @@ impl HerdrEnv { session: non_empty("PLANNOTATOR_TUI_SESSION").map(PathBuf::from), session_id: non_empty("PLANNOTATOR_TUI_SESSION_ID"), newest: env("PLANNOTATOR_TUI_NEWEST").as_deref() == Some("1"), + terminal_pane: non_empty("PLANNOTATOR_TUI_TERMINAL_PANE"), + terminal_lines: non_empty("PLANNOTATOR_TUI_TERMINAL_LINES").and_then(|v| v.parse().ok()), } } @@ -107,6 +113,24 @@ impl HerdrEnv { self.session.is_some() || self.session_id.is_some() || self.message_pid.is_some() } + /// The pane whose output the user asked to review: the focused pane when a plugin action + /// started us, else the caller's own pane (an agent or a script running the command). + pub(crate) fn reviewed_pane(&self) -> Option { + match &self.context { + Some(context) => context.focused_pane_id.clone(), + None => self.pane_id.clone(), + } + } + + /// The agent Herdr saw in `pane` when it snapshotted the context, if that pane was focused. + pub(crate) fn context_agent_in(&self, pane: &str) -> Option { + let context = self.context.as_ref()?; + (context.focused_pane_id.as_deref() == Some(pane)) + .then(|| context.focused_pane_agent.clone()) + .flatten() + .filter(|agent| !agent.trim().is_empty()) + } + /// Ask Herdr which agent runs in `pane` (`herdr pane get`), for the label when the /// launcher only knew the pane id. One short process at startup; `None` on any failure. pub(crate) fn agent_in_pane(&self, pane: &str) -> Option { @@ -207,4 +231,25 @@ mod tests { assert!(!env(&[("PLANNOTATOR_TUI_NEWEST", "")]).newest); assert!(!env(&[("PLANNOTATOR_TUI_NEWEST", "0")]).newest); } + + #[test] + fn the_reviewed_pane_is_the_focused_one_from_an_action_else_the_caller() { + let action = env(&[ + ("HERDR_PANE_ID", "w1:p9"), + ("HERDR_PLUGIN_CONTEXT_JSON", r#"{"focused_pane_id":"w1:p2","focused_pane_agent":"codex"}"#), + ]); + assert_eq!(action.reviewed_pane().as_deref(), Some("w1:p2")); + assert_eq!(action.context_agent_in("w1:p2").as_deref(), Some("codex")); + assert_eq!(action.context_agent_in("w1:p3"), None); + assert_eq!(env(&[("HERDR_PANE_ID", "w1:p9")]).reviewed_pane().as_deref(), Some("w1:p9")); + assert_eq!(env(&[("HERDR_PLUGIN_CONTEXT_JSON", "{}")]).reviewed_pane(), None); + } + + #[test] + fn the_terminal_pane_and_line_count_come_from_the_launcher() { + let env = + env(&[("PLANNOTATOR_TUI_TERMINAL_PANE", "w1:p2"), ("PLANNOTATOR_TUI_TERMINAL_LINES", "80")]); + assert_eq!(env.terminal_pane.as_deref(), Some("w1:p2")); + assert_eq!(env.terminal_lines, Some(80)); + } } diff --git a/crates/plannotator-tui/src/herdr/launch.rs b/crates/plannotator-tui/src/herdr/launch.rs index 8e21471..c7b2c80 100644 --- a/crates/plannotator-tui/src/herdr/launch.rs +++ b/crates/plannotator-tui/src/herdr/launch.rs @@ -43,6 +43,15 @@ pub(crate) struct Launch { pub(crate) session: Option, /// Open the newest reply straight away instead of showing the picker. pub(crate) newest: bool, + /// Open a pane's recent terminal output instead of `file`. + pub(crate) terminal: Option, +} + +/// Which pane's output the opened pane reads, and how much of it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TerminalRead { + pub(crate) pane: String, + pub(crate) lines: u32, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -153,6 +162,24 @@ pub(crate) fn plan_last( Ok(launch) } +/// Resolve a `terminal` launch: the reviewed pane's output opens in the doc pane, and feedback +/// goes where it would for `last` — the focused pane when an agent runs there, or +/// `--deliver-to` — else to the clipboard. +pub(crate) fn plan_terminal( + env: &HerdrEnv, + config: &Config, + args: OpenArgs, + cwd: &Path, + read: TerminalRead, +) -> Result { + let mut launch = plan(env, config, OpenArgs { path: None, newest: false, ..args }, cwd)?; + launch.file.clone_from(&launch.cwd); + // A split opens beside the pane being reviewed, whoever receives the feedback. + launch.target_pane = Some(read.pane.clone()); + launch.terminal = Some(read); + Ok(launch) +} + /// A `file://` URL as a local path; anything else is not ours to open. fn file_url_path(url: &str) -> Option { let rest = url.strip_prefix("file://")?; @@ -251,6 +278,7 @@ pub(crate) fn plan(env: &HerdrEnv, config: &Config, args: OpenArgs, cwd: &Path) message: None, session: None, newest: false, + terminal: None, }) } @@ -276,8 +304,12 @@ pub(crate) fn argv(launch: &Launch) -> Vec { } out.push("--focus".to_owned()); out.extend(["--cwd".to_owned(), launch.cwd.display().to_string()]); - match &launch.message { - Some(message) => { + match (&launch.message, &launch.terminal) { + (_, Some(terminal)) => { + out.extend(["--env".to_owned(), format!("PLANNOTATOR_TUI_TERMINAL_PANE={}", terminal.pane)]); + out.extend(["--env".to_owned(), format!("PLANNOTATOR_TUI_TERMINAL_LINES={}", terminal.lines)]); + } + (Some(message), None) => { if let Some(pid) = message.pid { out.extend(["--env".to_owned(), format!("PLANNOTATOR_TUI_MESSAGE_PID={pid}")]); } @@ -296,7 +328,9 @@ pub(crate) fn argv(launch: &Launch) -> Vec { None => {} } } - None => out.extend(["--env".to_owned(), format!("PLANNOTATOR_TUI_FILE={}", launch.file.display())]), + (None, None) => { + out.extend(["--env".to_owned(), format!("PLANNOTATOR_TUI_FILE={}", launch.file.display())]); + } } if let Some(target) = &launch.deliver { out.extend(["--env".to_owned(), format!("PLANNOTATOR_TUI_DELIVER_TO={}", target.pane)]); diff --git a/crates/plannotator-tui/src/herdr/launch/tests.rs b/crates/plannotator-tui/src/herdr/launch/tests.rs index 4c416df..30301f8 100644 --- a/crates/plannotator-tui/src/herdr/launch/tests.rs +++ b/crates/plannotator-tui/src/herdr/launch/tests.rs @@ -332,3 +332,48 @@ fn newest_reaches_the_pane_only_when_last_was_asked_for_it() { .expect("plans"); assert!(!argv(&open).iter().any(|a| a.starts_with("PLANNOTATOR_TUI_NEWEST"))); } + +#[test] +fn terminal_review_reads_the_focused_pane_and_delivers_to_its_agent() { + let context = HerdrContext { + focused_pane_id: Some("w1:p1".into()), + focused_pane_agent: Some("claude".into()), + focused_pane_cwd: Some("/w".into()), + ..HerdrContext::default() + }; + let read = TerminalRead { pane: "w1:p1".into(), lines: 200 }; + let launch = plan_terminal( + &env(None, Some(context)), + &Config::default(), + OpenArgs::default(), + Path::new("/"), + read, + ) + .expect("plans"); + let args = argv(&launch); + assert!(args.contains(&"PLANNOTATOR_TUI_TERMINAL_PANE=w1:p1".to_owned()), "{args:?}"); + assert!(args.contains(&"PLANNOTATOR_TUI_TERMINAL_LINES=200".to_owned()), "{args:?}"); + assert!(args.contains(&"PLANNOTATOR_TUI_DELIVER_TO=w1:p1".to_owned()), "{args:?}"); + assert!(args.contains(&"PLANNOTATOR_TUI_DELIVER_AGENT=claude".to_owned()), "{args:?}"); + assert!(!args.iter().any(|a| a.starts_with("PLANNOTATOR_TUI_FILE=")), "a terminal review opens no file"); + assert!(!args.iter().any(|a| a.starts_with("PLANNOTATOR_TUI_HOST=")), "nor an agent message"); +} + +#[test] +fn terminal_review_of_a_shell_splits_beside_it_and_delivers_nowhere() { + let context = HerdrContext { + focused_pane_id: Some("w1:p4".into()), + focused_pane_cwd: Some("/w".into()), + ..HerdrContext::default() + }; + let read = TerminalRead { pane: "w1:p4".into(), lines: 50 }; + let args = OpenArgs { placement: Some(Placement::Split), ..OpenArgs::default() }; + let launch = + plan_terminal(&env(Some("w9:p9"), Some(context)), &Config::default(), args, Path::new("/"), read) + .expect("plans"); + assert_eq!(launch.deliver, None, "no agent in the pane: the review falls back to the clipboard"); + let args = argv(&launch); + let target = args.iter().position(|a| a == "--target-pane").and_then(|i| args.get(i + 1)); + assert_eq!(target.map(String::as_str), Some("w1:p4")); + assert!(args.contains(&"PLANNOTATOR_TUI_TERMINAL_PANE=w1:p4".to_owned()), "{args:?}"); +} diff --git a/crates/plannotator-tui/src/herdr/mod.rs b/crates/plannotator-tui/src/herdr/mod.rs index a5e64d3..2f4f19a 100644 --- a/crates/plannotator-tui/src/herdr/mod.rs +++ b/crates/plannotator-tui/src/herdr/mod.rs @@ -5,3 +5,4 @@ pub(crate) mod context; pub(crate) mod launch; +pub(crate) mod terminal; diff --git a/crates/plannotator-tui/src/herdr/terminal.rs b/crates/plannotator-tui/src/herdr/terminal.rs new file mode 100644 index 0000000..1b32f31 --- /dev/null +++ b/crates/plannotator-tui/src/herdr/terminal.rs @@ -0,0 +1,264 @@ +//! `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. + +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; + +/// `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 +/// narrows the pane before the read does not re-break them. +pub(crate) fn read(env: &HerdrEnv, pane: &str, lines: u32) -> Result { + let output = Command::new(&env.bin) + .args(["pane", "read", pane, "--source", "recent-unwrapped", "--lines", &lines.to_string()]) + .args(["--format", "text"]) + .stdin(Stdio::null()) + .output() + .with_context(|| format!("running {} pane read", env.bin.display()))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("herdr pane read {pane}: {}", stderr.trim()); + } + let text = clean(&String::from_utf8_lossy(&output.stdout)); + anyhow::ensure!(!text.is_empty(), "pane {pane} has no output to review"); + 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<()> { + let text = read(env, pane, env.terminal_lines.unwrap_or(DEFAULT_LINES))?; + let agent = env.deliver_agent.clone().or_else(|| env.context_agent_in(pane)); + 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/tests/support/fake-herdr.rs b/crates/plannotator-tui/tests/support/fake-herdr.rs index a5c5c26..f0c4e7e 100644 --- a/crates/plannotator-tui/tests/support/fake-herdr.rs +++ b/crates/plannotator-tui/tests/support/fake-herdr.rs @@ -1,4 +1,5 @@ -//! Standalone argv recorder compiled by Windows tests with `rustc`. +//! Standalone argv recorder compiled by tests with `rustc`. `pane read` prints the +//! `pane-read.txt` fixture, or fails with `pane-read.error` on stderr when that exists. use std::fs::OpenOptions; use std::io::Write as _; @@ -67,6 +68,13 @@ fn main() { r#"{"result":{"process_info":{"foreground_process_group_id":42,"foreground_processes":[{"name":"codex","pid":42}]}}}"#, ) ), + ["pane", "read", ..] => match std::fs::read_to_string(root.join("pane-read.error")) { + Ok(error) => { + eprint!("{error}"); + std::process::exit(1); + } + Err(_) => print!("{}", fixture(root, "pane-read.txt", "")), + }, _ => print!(r#"{{"result":{{}}}}"#), } } diff --git a/crates/plannotator-tui/tests/terminal.rs b/crates/plannotator-tui/tests/terminal.rs new file mode 100644 index 0000000..bbae608 --- /dev/null +++ b/crates/plannotator-tui/tests/terminal.rs @@ -0,0 +1,172 @@ +//! `plannotator-tui herdr terminal` through the real binary against a recording fake `herdr`. + +#![allow(clippy::expect_used, clippy::indexing_slicing, reason = "tests assert by panicking")] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::{Value, json}; + +const OUTPUT: &str = "\u{1b}[32m$ cargo test\u{1b}[0m \r\ntest result: ok. 3 passed\n\n\n"; + +fn temp_root(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("plannotator terminal {tag} ü-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("temp root"); + root +} + +/// The fake compiled into `root/fake herdr/`, where it also reads fixtures and logs calls. +fn compile_fake(root: &Path) -> PathBuf { + let dir = root.join("fake herdr"); + std::fs::create_dir_all(&dir).expect("fake dir"); + let executable = dir.join(format!("herdr{}", std::env::consts::EXE_SUFFIX)); + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/support/fake-herdr.rs"); + let output = Command::new("rustc") + .arg("--edition=2024") + .arg(&source) + .arg("-o") + .arg(&executable) + .output() + .expect("run rustc"); + assert!(output.status.success(), "rustc failed: {}", String::from_utf8_lossy(&output.stderr)); + executable +} + +fn calls(fake: &Path) -> Vec { + let log = fake.parent().expect("fake dir").join("calls.jsonl"); + std::fs::read_to_string(log) + .unwrap_or_default() + .lines() + .map(|line| serde_json::from_str(line).expect("JSON call")) + .collect() +} + +/// Run `herdr terminal` as a plugin action would, focused on `w1:p1` where codex runs. +fn terminal(root: &Path, fake: &Path, extra: &[&str]) -> Output { + let context = + json!({"focused_pane_id": "w1:p1", "focused_pane_agent": "codex", "focused_pane_cwd": root}); + Command::new(env!("CARGO_BIN_EXE_plannotator-tui")) + .args(["herdr", "terminal"]) + .args(extra) + .current_dir(root) + .env("HERDR_ENV", "1") + .env("HERDR_BIN_PATH", fake) + .env("HERDR_PLUGIN_CONTEXT_JSON", context.to_string()) + .env("HERDR_PANE_ID", "w1:p9") + .env("PLANNOTATOR_TUI_CONFIG", root.join("absent.toml")) + .env_remove("PLANNOTATOR_TUI_PLACEMENT") + .output() + .expect("runs") +} + +#[test] +fn print_shows_the_focused_panes_cleaned_output_as_one_code_block() { + let root = temp_root("print"); + let fake = compile_fake(&root); + std::fs::write(fake.with_file_name("pane-read.txt"), OUTPUT).expect("fixture"); + + let out = terminal(&root, &fake, &["--lines", "40", "--print"]); + + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + "```text\n$ cargo test\ntest result: ok. 3 passed\n```\n" + ); + let calls = calls(&fake); + assert_eq!(calls.len(), 1, "{calls:?}"); + assert_eq!( + calls[0]["argv"], + json!(["pane", "read", "w1:p1", "--source", "recent-unwrapped", "--lines", "40", "--format", "text"]) + ); + std::fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn the_review_pane_opens_on_the_output_and_sends_to_the_agent_that_produced_it() { + let root = temp_root("launch"); + let fake = compile_fake(&root); + std::fs::write(fake.with_file_name("pane-read.txt"), OUTPUT).expect("fixture"); + + let out = terminal(&root, &fake, &[]); + + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let calls = calls(&fake); + assert_eq!(calls.len(), 2, "{calls:?}"); + assert_eq!(calls[0]["argv"][6], "200", "the default line count"); + let open: Vec<&str> = + calls[1]["argv"].as_array().expect("argv").iter().filter_map(Value::as_str).collect(); + assert_eq!(open.get(..3), Some(&["plugin", "pane", "open"][..])); + for env in [ + "PLANNOTATOR_TUI_TERMINAL_PANE=w1:p1", + "PLANNOTATOR_TUI_TERMINAL_LINES=200", + "PLANNOTATOR_TUI_DELIVER_TO=w1:p1", + "PLANNOTATOR_TUI_DELIVER_AGENT=codex", + ] { + assert!(open.contains(&env), "{env} missing from {open:?}"); + } + std::fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn a_pane_with_nothing_on_it_fails_without_opening_a_review() { + let root = temp_root("empty"); + let fake = compile_fake(&root); + std::fs::write(fake.with_file_name("pane-read.txt"), "\n \n\u{1b}[0m\n").expect("fixture"); + + let out = terminal(&root, &fake, &[]); + + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("pane w1:p1 has no output to review")); + assert_eq!(calls(&fake).len(), 1, "no pane opened"); + std::fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn a_failed_read_reports_herdrs_error_without_opening_a_review() { + let root = temp_root("error"); + let fake = compile_fake(&root); + std::fs::write( + fake.with_file_name("pane-read.error"), + r#"{"error":{"code":"pane_not_found","message":"pane w1:p1 not found"}}"#, + ) + .expect("fixture"); + + let out = terminal(&root, &fake, &[]); + + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("herdr pane read w1:p1") && stderr.contains("pane_not_found"), "{stderr}"); + assert_eq!(calls(&fake).len(), 1, "no pane opened"); + std::fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn an_action_without_a_focused_pane_reads_nothing() { + let root = temp_root("unfocused"); + let fake = compile_fake(&root); + + let out = Command::new(env!("CARGO_BIN_EXE_plannotator-tui")) + .args(["herdr", "terminal", "--print"]) + .env("HERDR_ENV", "1") + .env("HERDR_BIN_PATH", &fake) + .env("HERDR_PLUGIN_CONTEXT_JSON", r#"{"workspace_id":"w1"}"#) + .env("HERDR_PANE_ID", "w1:p9") + .output() + .expect("runs"); + + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("no focused pane to read")); + assert!(calls(&fake).is_empty()); + std::fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn line_counts_must_be_positive_numbers() { + let out = Command::new(env!("CARGO_BIN_EXE_plannotator-tui")) + .args(["herdr", "terminal", "--lines", "0"]) + .output() + .expect("runs"); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("--lines takes a number from 1")); +} diff --git a/docs/spec-herdr-integration.md b/docs/spec-herdr-integration.md index e6b6ee6..64087ee 100644 --- a/docs/spec-herdr-integration.md +++ b/docs/spec-herdr-integration.md @@ -279,6 +279,25 @@ the env above, resolves, and execs `herdr plugin pane open`. Resolution, in orde It passes `PLANNOTATOR_TUI_FILE`, `PLANNOTATOR_TUI_DELIVER_TO`, `PLANNOTATOR_TUI_DELIVER_AGENT` explicitly so the pane never has to guess. `argv` construction is a pure, tested function. +### `plannotator-tui herdr terminal [--lines N] [--placement P] [--deliver-to PANE] [--print]` + +Reviews a pane's recent output. The pane is the context's `focused_pane_id` (from an action), +else `HERDR_PANE_ID` (the caller). The launcher runs +`herdr pane read --source recent-unwrapped --lines N --format text` (default 200; +Herdr caps at 1000) and fails before any pane opens when the read fails or the cleaned text is +empty. Unwrapped lines are the program's own, so a split that narrows the pane does not +re-break them. Delivery and placement resolve as for `last`; a split opens beside the reviewed +pane. It passes `PLANNOTATOR_TUI_TERMINAL_PANE` and `PLANNOTATOR_TUI_TERMINAL_LINES` instead of +`PLANNOTATOR_TUI_FILE`, and the doc pane reads the pane again when it starts. + +The document is one fenced `text` block (the fence outlasts any backtick run in the output), +with escape sequences, control characters, trailing whitespace and surrounding blank lines +removed. Code blocks clip rather than wrap, so lines wider than the view are broken when the +pane opens: at a space when one fits, else inside the token. Feedback quotes rejoin a break +inside a token without a space (so a long path reads as printed), and carry no `(line N)` +label, which would count wrapped rows rather than output lines. It is transient (`Provenance::Stdin`): no sidecar, history, or drafts. +`--print` writes the document to stdout and opens nothing. + ### Modules and ownership ``` @@ -287,13 +306,14 @@ crates/plannotator-tui/src/config.rs Config, HerdrConfig, Placement, Sp crates/plannotator-tui/src/herdr/mod.rs pub(crate) mod context; pub(crate) mod launch; crates/plannotator-tui/src/herdr/context.rs HerdrContext (serde of the context JSON), HerdrEnv::from_env(), Target { pane, agent } -crates/plannotator-tui/src/herdr/launch.rs Launch, plan(), argv(), run() +crates/plannotator-tui/src/herdr/launch.rs Launch, plan(), plan_terminal(), argv(), run() +crates/plannotator-tui/src/herdr/terminal.rs read(), clean(), wrap(), document(): pane output as a review crates/plannotator-tui/src/delivery.rs DeliveryError { Blocked, Unavailable, Failed }, HerdrAgent { bin, pane, agent }, parse_response() crates/plannotator-tui/src/store.rs Record.deliveries, record_delivery(), all_delivered() crates/plannotator-tui/src/app/mod.rs SendState, send(), send_label(), is_agent target crates/plannotator-tui/src/app/draw.rs header Send button + geometry.send_button crates/plannotator-tui/src/app/input.rs button click, q → confirm when unsent -herdr/herdr-plugin.toml doc pane, open / open-link actions, link handler +herdr/herdr-plugin.toml doc pane, open / open-link / last / terminal actions, link handler skills/plannotator-tui/SKILL.md the agent instruction (draft; not wired yet) ``` diff --git a/herdr/herdr-plugin.toml b/herdr/herdr-plugin.toml index d3e6166..3dc1a30 100644 --- a/herdr/herdr-plugin.toml +++ b/herdr/herdr-plugin.toml @@ -53,6 +53,13 @@ title = "Annotate: agent's last message" contexts = ["pane"] command = ["./bin/plannotator-tui.exe", "herdr", "last"] +# The focused pane's recent output as a review; no default key. +[[actions]] +id = "terminal" +title = "Annotate: terminal output" +contexts = ["pane"] +command = ["./bin/plannotator-tui.exe", "herdr", "terminal"] + [[link_handlers]] id = "markdown-file" title = "Annotate this file" diff --git a/herdr/test-manifest.py b/herdr/test-manifest.py index dcb93c6..f0584b6 100755 --- a/herdr/test-manifest.py +++ b/herdr/test-manifest.py @@ -17,6 +17,7 @@ ("actions", "open"): [PROGRAM, "herdr", "open"], ("actions", "open-link"): [PROGRAM, "herdr", "open"], ("actions", "last"): [PROGRAM, "herdr", "last"], + ("actions", "terminal"): [PROGRAM, "herdr", "terminal"], } LINK_HANDLERS = {"markdown-file": "open-link"} DEVELOPMENT_BUILDS = [