From 728d0b2ce4cd8b4cf88231bbef8e03da6d7a033f Mon Sep 17 00:00:00 2001 From: mshddev Date: Wed, 16 Sep 2026 13:57:07 +0700 Subject: [PATCH 1/2] feat(last): open the newest reply without the picker `herdr last --newest` and `last --newest` open the agent's newest reply straight away. The launcher passes PLANNOTATOR_TUI_NEWEST=1 to the pane, which reads it as the flag. All candidates are kept, so `p` opens the picker on them exactly as escaping it would have. `herdr open` has no picker to skip and rejects the flag. Without it nothing changes. Co-Authored-By: Claude Opus 5 --- README.md | 6 +++- crates/plannotator-tui/src/app/pick.rs | 10 ++++-- crates/plannotator-tui/src/app/tests.rs | 23 +++++++++++- crates/plannotator-tui/src/cli.rs | 13 ++++--- crates/plannotator-tui/src/herdr/context.rs | 11 ++++++ crates/plannotator-tui/src/herdr/launch.rs | 10 ++++++ .../plannotator-tui/src/herdr/launch/tests.rs | 35 ++++++++++++++++++- crates/plannotator-tui/src/last/mod.rs | 4 +++ docs/spec-last-message.md | 7 +++- 9 files changed, 108 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 639af16..ef8c9fc 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,9 @@ the agent as its next message: `Send 3 new ▸ claude in w1:p2 (E)`. Folder revi 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 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. @@ -109,7 +112,8 @@ An explicit `--placement` or `PLANNOTATOR_TUI_PLACEMENT` still takes precedence. picker of its recent replies. Hosts: Claude Code, Codex, pi, Oh My Pi, GitHub Copilot CLI, Droid, Hermes CLI, OpenCode (1 and 2). `--host`, `--pid`, `--session ` (format sniffed when no host is named) and `--session-id ` (Hermes, OpenCode) override detection; `--stdin` -reads a document; +reads a document; `--newest` skips the picker and opens the newest reply straight away, with +`p` still opening the picker on the rest; `--print` writes the newest reply to stdout and always exits 0 (for hooks and scripts). A reply review keeps its annotations in memory only; nothing about it survives the run, but the feedback you send or copy is archived like any other (see Feedback archive below). diff --git a/crates/plannotator-tui/src/app/pick.rs b/crates/plannotator-tui/src/app/pick.rs index ef566f8..41586d8 100644 --- a/crates/plannotator-tui/src/app/pick.rs +++ b/crates/plannotator-tui/src/app/pick.rs @@ -23,6 +23,9 @@ impl App { /// Open with `messages` (newest first) as candidates; the picker shows when there is a /// choice to make. `transcript` is the path shown and archived; `session_id` is the /// host's own id for the session, when known. + /// + /// `newest` asks for the newest message and nothing in the way: the candidates are + /// still kept, so `p` opens the picker on them exactly as escaping it would have. pub(crate) fn open_message( host: &str, transcript: &str, @@ -30,14 +33,15 @@ impl App { messages: Vec, width: usize, delivery: Box, + newest: bool, ) -> Result { - let Some(newest) = messages.first() else { anyhow::bail!("no message to open") }; - let mut app = Self::open(message_source(host, session_id, newest), width, delivery)?; + let Some(first) = messages.first() else { anyhow::bail!("no message to open") }; + let mut app = Self::open(message_source(host, session_id, first), width, delivery)?; host.clone_into(&mut app.message_host); transcript.clone_into(&mut app.message_transcript); app.message_session = session_id.map(str::to_owned); app.candidates = messages; - if app.candidates.len() > 1 { + if app.candidates.len() > 1 && !newest { app.mode = Mode::Pick; } Ok(app) diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 324630d..cab8f54 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -41,8 +41,13 @@ fn app(delivery: Box) -> App { /// `App::open_message` on `candidates()`, isolated like `app`. fn message_app(session_id: Option<&str>, delivery: Box) -> App { + opened_message_app(session_id, delivery, false) +} + +/// `message_app`, with `newest` as `plannotator-tui last --newest` passes it. +fn opened_message_app(session_id: Option<&str>, delivery: Box, newest: bool) -> App { let mut app = - App::open_message("claude", "/tmp/transcript.jsonl", session_id, candidates(), 60, delivery) + App::open_message("claude", "/tmp/transcript.jsonl", session_id, candidates(), 60, delivery, newest) .expect("opens"); app.data_dir = scratch_data_dir(); app @@ -226,6 +231,22 @@ fn escaping_the_picker_keeps_the_newest_message() { assert_eq!(app.mode, Mode::Pick, "p reopens the picker"); } +#[test] +fn newest_opens_the_newest_reply_and_leaves_the_picker_on_p() { + let mut app = opened_message_app(None, Box::new(Discard), true); + assert_eq!(app.mode, Mode::Browse, "--newest has nothing to ask"); + assert_eq!(app.open.doc.source, "# Third\n\nnewest message\n"); + assert_eq!(app.candidates.len(), 3, "the other replies are still there to pick from"); + + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Char('p')))).expect("p"); + assert_eq!(app.mode, Mode::Pick, "p opens the picker that was never shown"); + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Char('j')))).expect("j"); + assert_eq!(app.open.doc.source, "# Second\n\nmiddle message\n"); + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Esc))).expect("esc"); + assert_eq!(app.mode, Mode::Browse); + assert_eq!(app.open.doc.source, "# Third\n\nnewest message\n", "esc returns to what was open"); +} + #[test] fn moving_the_picker_cursor_previews_that_message() { let mut app = message_app(None, Box::new(Discard)); diff --git a/crates/plannotator-tui/src/cli.rs b/crates/plannotator-tui/src/cli.rs index 2d0a3db..323c837 100644 --- a/crates/plannotator-tui/src/cli.rs +++ b/crates/plannotator-tui/src/cli.rs @@ -34,10 +34,10 @@ const USAGE: &str = "usage: plannotator-tui config plannotator-tui --version plannotator-tui herdr open [file.md | folder] [--placement overlay|split|popup] [--deliver-to ] - plannotator-tui herdr last [--placement P] [--deliver-to ] + plannotator-tui herdr last [--placement P] [--deliver-to ] [--newest] 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]"; + [--session-id ] [--stdin] [--print] [--pick N] [--newest]"; /// Width the document gets when nothing else is known: gutter + rail + gap subtracted. fn doc_width(cols: u16) -> usize { @@ -146,7 +146,8 @@ fn show_config() -> Result<()> { Ok(()) } -/// `plannotator-tui herdr open [PATH] [--placement P] [--deliver-to PANE]`. +/// `plannotator-tui herdr open [PATH] [--placement P] [--deliver-to PANE]`; +/// `herdr last` takes `--newest` on top, which `open` has nothing to skip. fn herdr_command(args: &[String]) -> Result<()> { use crate::herdr::launch::{OpenArgs, agent_get, agent_identity, plan, plan_last, process_info, run}; let sub = args.first().map(String::as_str); @@ -167,6 +168,7 @@ fn herdr_command(args: &[String]) -> Result<()> { "--deliver-to" => { open.deliver_to = Some(rest.next().context("--deliver-to needs a value")?.clone()); } + "--newest" if sub == Some("last") => open.newest = true, flag if flag.starts_with("--") => anyhow::bail!("unknown flag {flag}\n{USAGE}"), path if open.path.is_none() => open.path = Some(PathBuf::from(path)), extra => anyhow::bail!("unexpected argument {extra:?}\n{USAGE}"), @@ -201,6 +203,7 @@ fn herdr_pane() -> Result<()> { session: env.session.clone(), session_id: env.session_id.clone(), pick: 25, + newest: env.newest, ..crate::last::LastOptions::default() }) } else { @@ -219,7 +222,8 @@ fn herdr_pane() -> Result<()> { result } -/// `plannotator-tui last [--host H] [--pid N] [--session PATH] [--stdin] [--print] [--pick N]`. +/// `plannotator-tui last [--host H] [--pid N] [--session PATH] [--stdin] [--print] [--pick N] +/// [--newest]`. fn last_command(args: &[String]) -> Result<()> { use crate::last::LastOptions; let mut options = LastOptions { pick: 25, ..LastOptions::default() }; @@ -237,6 +241,7 @@ fn last_command(args: &[String]) -> Result<()> { "--stdin" => options.stdin = true, "--print" => options.print = true, "--pick" => options.pick = rest.next().context("--pick needs a value")?.parse()?, + "--newest" => options.newest = true, other => anyhow::bail!("unknown argument {other}\n{USAGE}"), } } diff --git a/crates/plannotator-tui/src/herdr/context.rs b/crates/plannotator-tui/src/herdr/context.rs index 379882c..a3478d5 100644 --- a/crates/plannotator-tui/src/herdr/context.rs +++ b/crates/plannotator-tui/src/herdr/context.rs @@ -53,6 +53,8 @@ pub(crate) struct HerdrEnv { pub(crate) session: Option, /// `PLANNOTATOR_TUI_SESSION_ID`: the agent's session id, for hosts without transcript files. pub(crate) session_id: Option, + /// `PLANNOTATOR_TUI_NEWEST=1`: open the newest reply straight away, no picker. + pub(crate) newest: bool, } impl HerdrEnv { @@ -78,6 +80,7 @@ impl HerdrEnv { host: non_empty("PLANNOTATOR_TUI_HOST"), 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"), } } @@ -194,4 +197,12 @@ mod tests { } assert!(!env(&[]).has_message_source()); } + + #[test] + fn only_an_exact_newest_flag_skips_the_picker() { + assert!(env(&[("PLANNOTATOR_TUI_NEWEST", "1")]).newest); + assert!(!env(&[]).newest); + assert!(!env(&[("PLANNOTATOR_TUI_NEWEST", "")]).newest); + assert!(!env(&[("PLANNOTATOR_TUI_NEWEST", "0")]).newest); + } } diff --git a/crates/plannotator-tui/src/herdr/launch.rs b/crates/plannotator-tui/src/herdr/launch.rs index 4c1669a..1f4a79d 100644 --- a/crates/plannotator-tui/src/herdr/launch.rs +++ b/crates/plannotator-tui/src/herdr/launch.rs @@ -17,6 +17,8 @@ pub(crate) struct OpenArgs { pub(crate) path: Option, pub(crate) placement: Option, pub(crate) deliver_to: Option, + /// `last` only: open the newest reply without offering the picker first. + pub(crate) newest: bool, } /// A fully resolved launch. @@ -39,6 +41,8 @@ pub(crate) struct Launch { pub(crate) message: Option, /// The agent's session as Herdr reports it: a transcript path or a host-specific id. pub(crate) session: Option, + /// Open the newest reply straight away instead of showing the picker. + pub(crate) newest: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -126,7 +130,9 @@ pub(crate) fn plan_last( process_info_json: Option<&str>, agent_get_json: Option<&str>, ) -> Result { + let newest = args.newest; let mut launch = plan(env, config, OpenArgs { path: None, ..args }, cwd)?; + launch.newest = newest; let pane = launch.deliver.as_ref().map(|t| t.pane.clone()).or_else(|| launch.target_pane.clone()); let Some(pane) = pane else { anyhow::bail!("no agent pane to read: not focused on one and no --deliver-to") @@ -244,6 +250,7 @@ pub(crate) fn plan(env: &HerdrEnv, config: &Config, args: OpenArgs, cwd: &Path) plugin: env.plugin_id.clone().unwrap_or_else(|| "plannotator-tui".to_owned()), message: None, session: None, + newest: false, }) } @@ -275,6 +282,9 @@ pub(crate) fn argv(launch: &Launch) -> Vec { out.extend(["--env".to_owned(), format!("PLANNOTATOR_TUI_MESSAGE_PID={pid}")]); } out.extend(["--env".to_owned(), format!("PLANNOTATOR_TUI_HOST={}", message.host)]); + if launch.newest { + out.extend(["--env".to_owned(), "PLANNOTATOR_TUI_NEWEST=1".to_owned()]); + } out.extend(["--env".to_owned(), format!("PLANNOTATOR_TUI_CWD={}", launch.cwd.display())]); match &launch.session { Some(AgentSession::Path(p)) => { diff --git a/crates/plannotator-tui/src/herdr/launch/tests.rs b/crates/plannotator-tui/src/herdr/launch/tests.rs index 9cf2986..376ed40 100644 --- a/crates/plannotator-tui/src/herdr/launch/tests.rs +++ b/crates/plannotator-tui/src/herdr/launch/tests.rs @@ -145,7 +145,11 @@ fn only_file_urls_are_opened() { #[test] fn popup_placement_emits_size_and_no_target_pane() { let config = Config::parse("[herdr]\npopup_width = \"100%\"\npopup_height = \"100%\"\n").expect("config"); - let args = OpenArgs { placement: Some(Placement::Popup), deliver_to: Some("w1:p1".into()), path: None }; + let args = OpenArgs { + placement: Some(Placement::Popup), + deliver_to: Some("w1:p1".into()), + ..OpenArgs::default() + }; let launch = plan(&env(Some("w1:p9"), None), &config, args, Path::new("/tmp")).expect("plans"); assert_eq!(launch.deliver, Some(Target { pane: "w1:p1".into(), agent: None }), "--deliver-to wins"); let args = argv(&launch); @@ -299,3 +303,32 @@ fn herdrs_agent_session_is_passed_to_the_pane_as_a_path_or_an_id() { let args = argv(&launch); assert!(args.contains(&"PLANNOTATOR_TUI_SESSION_ID=sess_abc123".to_owned()), "{args:?}"); } + +#[test] +fn newest_reaches_the_pane_only_when_last_was_asked_for_it() { + 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 last = |args| { + plan_last( + &env(None, Some(context.clone())), + &Config::default(), + args, + Path::new("/"), + None, + Some(AGENT_GET_PI), + ) + .expect("plans") + }; + let asked = OpenArgs { newest: true, ..OpenArgs::default() }; + assert!(argv(&last(asked)).contains(&"PLANNOTATOR_TUI_NEWEST=1".to_owned())); + assert!(!argv(&last(OpenArgs::default())).iter().any(|a| a.starts_with("PLANNOTATOR_TUI_NEWEST"))); + + // `herdr open` has no picker to skip, so a document launch never carries the flag. + let open = plan(&env(None, Some(context)), &Config::default(), OpenArgs::default(), Path::new("/")) + .expect("plans"); + assert!(!argv(&open).iter().any(|a| a.starts_with("PLANNOTATOR_TUI_NEWEST"))); +} diff --git a/crates/plannotator-tui/src/last/mod.rs b/crates/plannotator-tui/src/last/mod.rs index 1c0e08c..b378896 100644 --- a/crates/plannotator-tui/src/last/mod.rs +++ b/crates/plannotator-tui/src/last/mod.rs @@ -39,6 +39,8 @@ pub(crate) struct LastOptions { pub(crate) print: bool, /// How many recent messages the picker offers. pub(crate) pick: usize, + /// Open the newest message straight away; the picker waits behind `p`. + pub(crate) newest: bool, } pub(crate) fn run(options: &LastOptions) -> Result<()> { @@ -83,6 +85,7 @@ pub(crate) fn run(options: &LastOptions) -> Result<()> { let session_id = located.session_id; let messages = located.messages; let note = discovery_note(located.discovery, crate::herdr::context::HerdrEnv::from_env().in_herdr); + let newest = options.newest; cli::run_ui(|width| { let mut app = App::open_message( label, @@ -91,6 +94,7 @@ pub(crate) fn run(options: &LastOptions) -> Result<()> { messages, width, cli::delivery(true), + newest, )?; if let Some(note) = note { app.set_status(note); diff --git a/docs/spec-last-message.md b/docs/spec-last-message.md index aee281e..ab47753 100644 --- a/docs/spec-last-message.md +++ b/docs/spec-last-message.md @@ -72,6 +72,7 @@ user entry, an `isSidechain` entry, bookkeeping entries without uuids written la PLANNOTATOR_TUI_MESSAGE_PID open the last message of the agent with this pid (launcher → pane) PLANNOTATOR_TUI_HOST claude | codex; overrides detection (any context) PLANNOTATOR_TUI_SESSION explicit transcript path; skips detection (any context) +PLANNOTATOR_TUI_NEWEST `1`: open the newest reply, no picker (launcher → pane) ``` `plannotator-tui herdr pane` precedence: `PLANNOTATOR_TUI_MESSAGE_PID` → `PLANNOTATOR_TUI_FILE` @@ -80,9 +81,11 @@ PLANNOTATOR_TUI_SESSION explicit transcript path; skips detection (any con ## CLI ``` -plannotator-tui last [--host H] [--pid N] [--session PATH] [--stdin] [--print] [--pick N] +plannotator-tui last [--host H] [--pid N] [--session PATH] [--stdin] [--print] [--pick N] [--newest] ``` - default: detect → find → picker of the newest 25 assistant messages → annotate → send. +- `--newest`: open the newest message instead of the picker. The other candidates are kept, + so `p` opens the picker on them exactly as escaping it would have. - `--print`: newest message text on stdout, exit 0 (the delivery contract from decision 9). - `--stdin`: the document is stdin; no detection. - Errors name what was searched: "no Claude Code transcript for pid 1234 (looked in …)". @@ -94,5 +97,7 @@ plannotator-tui last [--host H] [--pid N] [--session PATH] [--stdin] [--print] [ name identifies the agent (`claude`, `codex`; else the foreground group leader) → pid; host from that name; then `plugin pane open` as `herdr open` does, with `PLANNOTATOR_TUI_MESSAGE_PID`, `PLANNOTATOR_TUI_HOST`, `PLANNOTATOR_TUI_DELIVER_TO`. +- `herdr last --newest` adds `PLANNOTATOR_TUI_NEWEST=1`, which the pane reads as `--newest`. + `herdr open` has no picker to skip and rejects the flag. - In the pane, `find_transcript` starts at that pid (`sessions/.json` is a direct hit). - Manifest pane command becomes `plannotator-tui herdr pane`. From 123f77b76d12f7d12a15f7eee0a3c5c64467c3cb Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 16 Sep 2026 08:51:44 -0700 Subject: [PATCH 2/2] fix(cli): name the subcommand when herdr open rejects --newest --- crates/plannotator-tui/src/cli.rs | 3 +++ crates/plannotator-tui/tests/last.rs | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/crates/plannotator-tui/src/cli.rs b/crates/plannotator-tui/src/cli.rs index 323c837..8f2fa47 100644 --- a/crates/plannotator-tui/src/cli.rs +++ b/crates/plannotator-tui/src/cli.rs @@ -169,6 +169,9 @@ fn herdr_command(args: &[String]) -> Result<()> { open.deliver_to = Some(rest.next().context("--deliver-to needs a value")?.clone()); } "--newest" if sub == Some("last") => open.newest = true, + "--newest" => { + anyhow::bail!("--newest is only for `herdr last`; `herdr open` has no picker to skip") + } flag if flag.starts_with("--") => anyhow::bail!("unknown flag {flag}\n{USAGE}"), path if open.path.is_none() => open.path = Some(PathBuf::from(path)), extra => anyhow::bail!("unexpected argument {extra:?}\n{USAGE}"), diff --git a/crates/plannotator-tui/tests/last.rs b/crates/plannotator-tui/tests/last.rs index 7a52f8b..10c3532 100644 --- a/crates/plannotator-tui/tests/last.rs +++ b/crates/plannotator-tui/tests/last.rs @@ -283,3 +283,16 @@ fn hermes_reads_the_session_named_by_id_from_hermes_home() { assert!(String::from_utf8_lossy(&missing.stderr).contains("needs a session id")); std::fs::remove_dir_all(&home).expect("cleanup"); } + +#[test] +fn herdr_open_rejects_newest_by_name() { + let out = bin().args(["herdr", "open", "--newest"]).output().expect("runs"); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("only for `herdr last`"), "{stderr}"); + assert!(!stderr.contains("unknown flag"), "{stderr}"); + + let out = bin().args(["herdr", "open", "--bogus"]).output().expect("runs"); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("unknown flag --bogus")); +}