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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 <transcript>` (format sniffed when
no host is named) and `--session-id <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).
Expand Down
10 changes: 7 additions & 3 deletions crates/plannotator-tui/src/app/pick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,25 @@ 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,
session_id: Option<&str>,
messages: Vec<Message>,
width: usize,
delivery: Box<dyn crate::delivery::Delivery>,
newest: bool,
) -> Result<Self> {
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)
Expand Down
23 changes: 22 additions & 1 deletion crates/plannotator-tui/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,13 @@ fn app(delivery: Box<dyn Delivery>) -> App {

/// `App::open_message` on `candidates()`, isolated like `app`.
fn message_app(session_id: Option<&str>, delivery: Box<dyn Delivery>) -> 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<dyn Delivery>, 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
Expand Down Expand Up @@ -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));
Expand Down
16 changes: 12 additions & 4 deletions crates/plannotator-tui/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pane>]
plannotator-tui herdr last [--placement P] [--deliver-to <pane>]
plannotator-tui herdr last [--placement P] [--deliver-to <pane>] [--newest]
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]";
[--session-id <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 {
Expand Down Expand Up @@ -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);
Expand All @@ -167,6 +168,10 @@ 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,
"--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}"),
Expand Down Expand Up @@ -201,6 +206,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 {
Expand All @@ -219,7 +225,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() };
Expand All @@ -237,6 +244,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}"),
}
}
Expand Down
11 changes: 11 additions & 0 deletions crates/plannotator-tui/src/herdr/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ pub(crate) struct HerdrEnv {
pub(crate) session: Option<PathBuf>,
/// `PLANNOTATOR_TUI_SESSION_ID`: the agent's session id, for hosts without transcript files.
pub(crate) session_id: Option<String>,
/// `PLANNOTATOR_TUI_NEWEST=1`: open the newest reply straight away, no picker.
pub(crate) newest: bool,
}

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

Expand Down Expand Up @@ -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);
}
}
10 changes: 10 additions & 0 deletions crates/plannotator-tui/src/herdr/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ pub(crate) struct OpenArgs {
pub(crate) path: Option<PathBuf>,
pub(crate) placement: Option<Placement>,
pub(crate) deliver_to: Option<String>,
/// `last` only: open the newest reply without offering the picker first.
pub(crate) newest: bool,
}

/// A fully resolved launch.
Expand All @@ -39,6 +41,8 @@ pub(crate) struct Launch {
pub(crate) message: Option<AgentMessage>,
/// The agent's session as Herdr reports it: a transcript path or a host-specific id.
pub(crate) session: Option<AgentSession>,
/// Open the newest reply straight away instead of showing the picker.
pub(crate) newest: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -126,7 +130,9 @@ pub(crate) fn plan_last(
process_info_json: Option<&str>,
agent_get_json: Option<&str>,
) -> Result<Launch> {
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")
Expand Down Expand Up @@ -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,
})
}

Expand Down Expand Up @@ -275,6 +282,9 @@ pub(crate) fn argv(launch: &Launch) -> Vec<String> {
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)) => {
Expand Down
35 changes: 34 additions & 1 deletion crates/plannotator-tui/src/herdr/launch/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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")));
}
4 changes: 4 additions & 0 deletions crates/plannotator-tui/src/last/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()> {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions crates/plannotator-tui/tests/last.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
7 changes: 6 additions & 1 deletion docs/spec-last-message.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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 …)".
Expand All @@ -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/<pid>.json` is a direct hit).
- Manifest pane command becomes `plannotator-tui herdr pane`.
Loading