Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -189,6 +195,7 @@ plannotator-tui --export <file|folder> # all active not
plannotator-tui --annotate <file> <quote> <text> [comment|looks_good|delete] [--occurrence N]
plannotator-tui --snapshot <file|folder> [cols rows scroll] [quote] # one frame as text
plannotator-tui --bench <file> # parse / layout timings
plannotator-tui herdr terminal [--lines N] --print # a Herdr pane's recent output as a document
```

## Repository
Expand Down
3 changes: 2 additions & 1 deletion crates/plannotator-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -54,6 +54,7 @@ plannotator-tui --blocks <file.md> # block index, kind, firs
plannotator-tui --annotate <file.md> <quote> <text> [comment|looks_good|delete] [--occurrence N]
plannotator-tui --annotate-block <file.md> <block> <text>
plannotator-tui --snapshot <file|folder> [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)
Expand Down
14 changes: 10 additions & 4 deletions crates/plannotator-tui/src/app/feedback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ pub(super) struct Feedback {
pub(super) parts: Vec<FeedbackPart>,
pub(super) annotations: Vec<AnnotationRecord>,
pub(super) counts: HashMap<PathBuf, ReviewCounts>,
/// Set for a terminal review: no line labels, and quotes rejoin these breaks.
pub(super) terminal_breaks: Option<Vec<usize>>,
}

impl Feedback {
Expand All @@ -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() {
Expand All @@ -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| {
Expand Down Expand Up @@ -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
}
Expand Down
9 changes: 9 additions & 0 deletions crates/plannotator-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<Vec<usize>>,
compose: Compose,
/// Whether the terminal reports Shift+Enter distinctly (kitty keyboard protocol).
pub(super) shift_enter: bool,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<usize>) {
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);
Expand Down
51 changes: 50 additions & 1 deletion crates/plannotator-tui/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const USAGE: &str = "usage:
plannotator-tui --version
plannotator-tui herdr open [file.md | folder] [--placement overlay|split|popup] [--deliver-to <pane>]
plannotator-tui herdr last [--placement P] [--deliver-to <pane>] [--newest]
plannotator-tui herdr terminal [--lines N] [--placement P] [--deliver-to <pane>] [--print]
plannotator-tui herdr pane
plannotator-tui last [--host claude|codex|pi|omp|copilot|droid|hermes|opencode] [--pid N] [--session <transcript>]
[--session-id <id>] [--stdin] [--print] [--pick N] [--newest]";
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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::<u32>().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,
Expand Down
48 changes: 36 additions & 12 deletions crates/plannotator-tui/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,
/// 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 => {
Expand Down Expand Up @@ -71,14 +74,24 @@ 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<usize>, 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, usize) {
let line_at = |offset: usize| source.get(..offset).map_or(1, |s| s.matches('\n').count() + 1);
(line_at(range.start), line_at(range.end.saturating_sub(1).max(range.start)))
}

#[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};
Expand Down Expand Up @@ -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\
Expand All @@ -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"));
Expand Down
45 changes: 45 additions & 0 deletions crates/plannotator-tui/src/herdr/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ pub(crate) struct HerdrEnv {
pub(crate) session_id: Option<String>,
/// `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<String>,
/// `PLANNOTATOR_TUI_TERMINAL_LINES`: how many recent lines to read from that pane.
pub(crate) terminal_lines: Option<u32>,
}

impl HerdrEnv {
Expand All @@ -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()),
}
}

Expand All @@ -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<String> {
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<String> {
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<String> {
Expand Down Expand Up @@ -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));
}
}
Loading
Loading