diff --git a/SKILL.md b/SKILL.md index 600ff6c7..599529ff 100644 --- a/SKILL.md +++ b/SKILL.md @@ -40,7 +40,7 @@ plain shells still exist as panes, but herdr's sidebar agent section intentional **ids** — workspace ids look like `1`, `2`. tab ids look like `1:1`, `1:2`, `2:1`. pane ids look like `1-1`, `1-2`, `2-1`. these are compact public ids for the current live session. -important: ids can compact when tabs, panes, or workspaces are closed. do not treat them as durable ids. +important: ids can compact when tabs, panes, or workspaces are closed. do not treat them as durable ids. re-read ids from `workspace list`, `tab list`, `pane list`, or create/split responses when you need a current id. do not guess that an older `1-3` is still the same pane later. ## discover yourself @@ -99,7 +99,8 @@ herdr pane read 1-1 --source recent --lines 50 ``` - `--source visible` = current viewport -- `--source recent` = recent scrollback +- `--source recent` = recent scrollback as rendered in the pane +- `--source recent-unwrapped` = recent terminal text with soft wraps joined back together ## split a pane and run a command @@ -109,10 +110,11 @@ split your pane to the right and keep focus on your current pane: herdr pane split 1-2 --direction right --no-focus ``` -that prints json with the new pane id. read the returned `pane_id`, then run a command in that pane: +that prints json with the new pane nested at `result.pane.pane_id`. parse that value, then run a command in that pane: ```bash -herdr pane run 1-3 "npm run dev" +NEW_PANE=$(herdr pane split 1-2 --direction right --no-focus | python3 -c 'import sys,json; print(json.load(sys.stdin)["result"]["pane"]["pane_id"])') +herdr pane run "$NEW_PANE" "npm run dev" ``` split downward instead: @@ -123,7 +125,9 @@ herdr pane split 1-2 --direction down --no-focus ## wait for output -block until specific text appears in a pane. useful for waiting on servers, builds, and tests: +block until specific text appears in a pane. useful for waiting on servers, builds, and tests. + +for `--source recent`, matching uses unwrapped recent terminal text, so pane width and soft wrapping do not break matches. `pane read --source recent` still shows the pane as rendered. if you want to inspect the same transcript that the waiter matches, use `pane read --source recent-unwrapped`. ```bash herdr wait output 1-3 --match "ready on port 3000" --timeout 30000 @@ -210,18 +214,10 @@ herdr pane close 1-3 ### run a server and wait until it is ready ```bash -# split a pane for the server -herdr pane split 1-2 --direction right --no-focus -# read the returned JSON to get the new pane_id, for example 1-3 - -# start the server -herdr pane run 1-3 "npm run dev" - -# wait until it is ready -herdr wait output 1-3 --match "ready" --timeout 30000 - -# read the output to confirm -herdr pane read 1-3 --source recent --lines 20 +NEW_PANE=$(herdr pane split 1-2 --direction right --no-focus | python3 -c 'import sys,json; print(json.load(sys.stdin)["result"]["pane"]["pane_id"])') +herdr pane run "$NEW_PANE" "npm run dev" +herdr wait output "$NEW_PANE" --match "ready" --timeout 30000 +herdr pane read "$NEW_PANE" --source recent --lines 20 ``` ### run tests in a separate pane and inspect the result @@ -240,6 +236,22 @@ herdr pane list herdr pane read 1-1 --source recent --lines 80 ``` +### watch another pane robustly + +use this pattern when you need to coordinate with a sibling pane: + +```bash +# inspect what is already there +herdr pane read 1-3 --source recent --lines 40 + +# wait only for the next output you expect +herdr wait output 1-3 --match "ready" --timeout 30000 + +# if you need to inspect the same transcript the waiter matched, +# read the unwrapped recent text directly +herdr pane read 1-3 --source recent-unwrapped --lines 40 +``` + ### spawn a new agent and give it a task ```bash @@ -260,7 +272,9 @@ herdr pane read 1-1 --source recent --lines 100 - `workspace list`, `workspace create`, `tab list`, `tab create`, `tab get`, `tab focus`, `tab rename`, `tab close`, `pane list`, `pane get`, `pane split`, `wait output`, and `wait agent-state` print json on success. - `pane read` prints text, not json. +- `pane read --source recent-unwrapped` is useful when you want to inspect the same unwrapped transcript that `wait output --source recent` matches against. - `pane send-text`, `pane send-keys`, and `pane run` print nothing on success. -- parse ids from `workspace create`, `tab create`, and `pane split` responses when you need new ids. do not guess. +- parse ids from `workspace create`, `tab create`, and `pane split` responses when you need new ids. for `pane split`, the new pane id is at `result.pane.pane_id`. +- use `pane read` for current output that already exists. use `wait output` for future output you expect next. - `--no-focus` on split, tab create, and workspace create keeps your current terminal context focused. - if you are running inside herdr, the `HERDR_ENV` environment variable is set to `1`. diff --git a/SOCKET_API.md b/SOCKET_API.md index 24b3662e..c145aa53 100644 --- a/SOCKET_API.md +++ b/SOCKET_API.md @@ -576,10 +576,12 @@ matcher forms: notes: -- `source` must be `visible` or `recent` +- `source` must be `visible`, `recent`, or `recent_unwrapped` - `lines` is optional - `timeout_ms` is optional - `strip_ansi` defaults to `true` +- for `source = "recent"`, output matching uses unwrapped recent terminal text so soft wraps do not break matches +- `source = "recent_unwrapped"` is also available on `pane.read` when you want to inspect the same unwrapped transcript directly - on success you get `output_matched` - on timeout you get an error response with code `timeout` @@ -597,7 +599,7 @@ example success response: "pane_id": "1-1", "workspace_id": "1", "tab_id": "1:1", - "source": "recent", + "source": "recent_unwrapped", "text": "...server ready...", "revision": 0, "truncated": false @@ -748,7 +750,7 @@ example pushed `pane.output_matched` event: "pane_id": "1-1", "workspace_id": "1", "tab_id": "1:1", - "source": "recent", + "source": "recent_unwrapped", "text": "...server ready...", "revision": 0, "truncated": false @@ -804,7 +806,7 @@ pane commands: ```text herdr pane list [--workspace ] herdr pane get -herdr pane read [--source visible|recent] [--lines N] [--raw] +herdr pane read [--source visible|recent|recent-unwrapped] [--lines N] [--raw] herdr pane split --direction right|down [--cwd PATH] [--no-focus] herdr pane close herdr pane send-text @@ -815,7 +817,7 @@ herdr pane run wait commands: ```text -herdr wait output --match [--source visible|recent] [--lines N] [--timeout MS] [--regex] [--raw] +herdr wait output --match [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex] [--raw] herdr wait agent-state --state [--timeout MS] ``` @@ -825,11 +827,13 @@ herdr wait agent-state --state [--timeo - `tab create` focuses by default; pass `--no-focus` to keep focus where it is - `pane split` focuses the new pane by default; pass `--no-focus` to keep focus on the original pane - `pane read` prints **text**, not json +- `pane read --source recent-unwrapped` returns recent terminal text with soft wraps joined back together - `pane send-text`, `pane send-keys`, and `pane run` print nothing on success - list/get/create/split/wait commands print json on success - `pane run` is a convenience wrapper for `pane send-text` + `pane send-keys Enter` - `wait agent-state` is a cli convenience built on top of event subscriptions - `--raw` disables ansi stripping for `pane read` and `wait output` +- `wait output --source recent` matches against unwrapped recent terminal text by default, so pane width and soft wrapping do not break matches ### cli examples diff --git a/src/api/mod.rs b/src/api/mod.rs index 1a713323..27317403 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -248,6 +248,15 @@ fn handle_request(request: Request, api_tx: &ApiRequestSender) -> String { } } +fn output_match_read_source( + source: &crate::api::schema::ReadSource, +) -> crate::api::schema::ReadSource { + match source { + crate::api::schema::ReadSource::Recent => crate::api::schema::ReadSource::RecentUnwrapped, + other => other.clone(), + } +} + fn wait_for_output( request_id: String, params: crate::api::schema::PaneWaitForOutputParams, @@ -279,7 +288,7 @@ fn wait_for_output( id: format!("{request_id}:read"), method: Method::PaneRead(crate::api::schema::PaneReadParams { pane_id: params.pane_id.clone(), - source: params.source.clone(), + source: output_match_read_source(¶ms.source), lines: params.lines, strip_ansi: params.strip_ansi, }), @@ -587,7 +596,7 @@ impl ActiveOutputMatchedSubscription { let read = pane_read( format!("{}:read", self.request_prefix), &self.pane_id, - self.source.clone(), + output_match_read_source(&self.source), self.lines, self.strip_ansi, api_tx, diff --git a/src/api/schema.rs b/src/api/schema.rs index 5a27b01b..e266f0ff 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -197,6 +197,7 @@ pub struct PaneReleaseAgentParams { pub enum ReadSource { Visible, Recent, + RecentUnwrapped, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/app/mod.rs b/src/app/mod.rs index 9020658a..2c5d3855 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1566,6 +1566,7 @@ impl App { let text = match params.source { ReadSource::Visible => pane.visible_text(), ReadSource::Recent => pane.recent_text(requested_lines), + ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), }; SuccessResponse { id: request.id, diff --git a/src/cli.rs b/src/cli.rs index 0516b3ce..cc364051 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -429,7 +429,7 @@ fn pane_get(args: &[String]) -> std::io::Result { fn pane_read(args: &[String]) -> std::io::Result { let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane read [--source visible|recent] [--lines N]"); + eprintln!("usage: herdr pane read [--source visible|recent|recent-unwrapped] [--lines N]"); return Ok(2); }; @@ -763,7 +763,7 @@ fn integration_uninstall(args: &[String]) -> std::io::Result { fn wait_output(args: &[String]) -> std::io::Result { let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr wait output --match [--source visible|recent] [--lines N] [--timeout MS] [--regex]"); + eprintln!("usage: herdr wait output --match [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex]"); return Ok(2); }; @@ -1016,6 +1016,7 @@ fn parse_read_source(value: &str) -> std::io::Result { match value { "visible" => Ok(ReadSource::Visible), "recent" => Ok(ReadSource::Recent), + "recent-unwrapped" | "recent_unwrapped" => Ok(ReadSource::RecentUnwrapped), _ => Err(std::io::Error::other(format!( "invalid read source: {value}" ))), @@ -1070,7 +1071,7 @@ fn print_pane_help() { eprintln!("herdr pane commands:"); eprintln!(" herdr pane list [--workspace ]"); eprintln!(" herdr pane get "); - eprintln!(" herdr pane read [--source visible|recent] [--lines N]"); + eprintln!(" herdr pane read [--source visible|recent|recent-unwrapped] [--lines N]"); eprintln!(" herdr pane split --direction right|down [--cwd PATH] [--no-focus]"); eprintln!(" herdr pane close "); eprintln!(" herdr pane send-text "); @@ -1080,7 +1081,7 @@ fn print_pane_help() { fn print_wait_help() { eprintln!("herdr wait commands:"); - eprintln!(" herdr wait output --match [--source visible|recent] [--lines N] [--timeout MS] [--regex]"); + eprintln!(" herdr wait output --match [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex]"); eprintln!( " herdr wait agent-state --state [--timeout MS]" ); diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 297b0e6b..55bafebc 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -461,11 +461,27 @@ impl Terminal { end: (u16, u32), rectangle: bool, ) -> Result { - let selection = GhosttyTerminalSelection { + self.read_text_selection(GhosttyTerminalSelection { start: ghostty_viewport_point(start.0, start.1), end: ghostty_viewport_point(end.0, end.1), rectangle, - }; + }) + } + + pub fn read_text_screen( + &self, + start: (u16, u32), + end: (u16, u32), + rectangle: bool, + ) -> Result { + self.read_text_selection(GhosttyTerminalSelection { + start: ghostty_screen_point(start.0, start.1), + end: ghostty_screen_point(end.0, end.1), + rectangle, + }) + } + + fn read_text_selection(&self, selection: GhosttyTerminalSelection) -> Result { let mut out_ptr = ptr::null_mut(); let mut out_len = 0usize; unsafe { @@ -570,6 +586,15 @@ fn ghostty_viewport_point(x: u16, y: u32) -> ffi::GhosttyPoint { } } +fn ghostty_screen_point(x: u16, y: u32) -> ffi::GhosttyPoint { + ffi::GhosttyPoint { + tag: ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_SCREEN, + value: ffi::GhosttyPointValue { + coordinate: ffi::GhosttyPointCoordinate { x, y }, + }, + } +} + pub struct RenderState { raw: ffi::GhosttyRenderState_ptr, } diff --git a/src/pane.rs b/src/pane.rs index e2afccdc..dcddabf4 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -345,6 +345,10 @@ impl PaneTerminal { self.ghostty.recent_text(lines) } + fn recent_unwrapped_text(&self, lines: usize) -> String { + self.ghostty.recent_unwrapped_text(lines) + } + fn extract_selection(&self, selection: &crate::selection::Selection) -> Option { self.ghostty.extract_selection(selection) } @@ -648,6 +652,14 @@ impl GhosttyPaneTerminal { .unwrap_or_default() } + fn recent_unwrapped_text(&self, lines: usize) -> String { + self.core + .lock() + .ok() + .and_then(|core| ghostty_recent_text_unwrapped(&core, lines).ok()) + .unwrap_or_default() + } + fn extract_selection(&self, selection: &crate::selection::Selection) -> Option { self.core .lock() @@ -1055,6 +1067,21 @@ fn ghostty_recent_text( Ok(recent_text_from_rows(&rows, lines)) } +fn ghostty_recent_text_unwrapped( + core: &GhosttyPaneCore, + lines: usize, +) -> Result { + let total_rows = core.terminal.total_rows()?; + let cols = core.terminal.cols()?; + if total_rows == 0 || cols == 0 { + return Ok(String::new()); + } + let start = total_rows.saturating_sub(lines) as u32; + let end = (total_rows.saturating_sub(1)) as u32; + core.terminal + .read_text_screen((0, start), (cols.saturating_sub(1), end), false) +} + fn ghostty_extract_selection( core: &mut GhosttyPaneCore, selection: &crate::selection::Selection, @@ -1685,6 +1712,10 @@ impl PaneRuntime { self.terminal.recent_text(lines) } + pub fn recent_unwrapped_text(&self, lines: usize) -> String { + self.terminal.recent_unwrapped_text(lines) + } + pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option { self.terminal.extract_selection(selection) } @@ -1963,6 +1994,17 @@ mod tests { assert!(pane.visible_text().contains("000000")); } + #[test] + fn recent_unwrapped_text_ignores_soft_wraps() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(5, 3, 100).unwrap(); + terminal.write(b"ABCDEFGHIJ"); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + assert_eq!(pane.recent_text(3), "ABCDE\nFGHIJ\n"); + assert_eq!(pane.recent_unwrapped_text(3), "ABCDEFGHIJ"); + } + #[test] fn synchronized_output_suppresses_intermediate_render_requests_until_batch_ends() { let (tx, _rx) = mpsc::channel(4); diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index 190ae70c..dc091e80 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -339,6 +339,81 @@ fn pane_run_read_and_wait_commands_work() { cleanup_spawned_herdr(herdr, base); } +#[test] +fn wait_output_matches_recent_unwrapped_text() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(created.status.success()); + + let token = "WRAP_WAIT_TEST_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789"; + let script = base.join("emit-long-token.sh"); + std::fs::write(&script, format!("#!/bin/sh\nprintf '%s\\n' '{token}'\n")).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).unwrap(); + } + + let run = run_cli( + &socket_path, + &["pane", "run", "1-1", &format!("sh {}", script.display())], + ); + assert!(run.status.success()); + + let waited = run_cli( + &socket_path, + &[ + "wait", + "output", + "1-1", + "--match", + token, + "--source", + "recent", + "--lines", + "80", + "--timeout", + "5000", + ], + ); + assert!( + waited.status.success(), + "stderr: {} stdout: {}", + String::from_utf8_lossy(&waited.stderr), + String::from_utf8_lossy(&waited.stdout) + ); + + let read = run_cli( + &socket_path, + &[ + "pane", + "read", + "1-1", + "--source", + "recent-unwrapped", + "--lines", + "80", + ], + ); + assert!(read.status.success()); + let text = String::from_utf8(read.stdout).unwrap(); + assert!(text.contains(token)); + + cleanup_spawned_herdr(herdr, base); +} + #[test] fn closing_pane_terminates_processes_inside_it() { let base = unique_test_dir();