aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-07 02:48:13 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-07 02:48:13 +0300
commit5e05460816bbcea61a111bfa57be9907116e8ffc (patch)
tree86309a79cd7f49b82e0129de131c6723365a14a1
parent06f01df93d895298508173cd59a8cf8eb2dd5bdc (diff)
docs: clarify herdr pane split and wait output usage
-rw-r--r--SKILL.md50
-rw-r--r--SOCKET_API.md14
-rw-r--r--src/api/mod.rs13
-rw-r--r--src/api/schema.rs1
-rw-r--r--src/app/mod.rs1
-rw-r--r--src/cli.rs9
-rw-r--r--src/ghostty/mod.rs29
-rw-r--r--src/pane.rs42
-rw-r--r--tests/cli_wrapper.rs75
9 files changed, 203 insertions, 31 deletions
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 <workspace_id>]
herdr pane get <pane_id>
-herdr pane read <pane_id> [--source visible|recent] [--lines N] [--raw]
+herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--raw]
herdr pane split <pane_id> --direction right|down [--cwd PATH] [--no-focus]
herdr pane close <pane_id>
herdr pane send-text <pane_id> <text>
@@ -815,7 +817,7 @@ herdr pane run <pane_id> <command>
wait commands:
```text
-herdr wait output <pane_id> --match <text> [--source visible|recent] [--lines N] [--timeout MS] [--regex] [--raw]
+herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex] [--raw]
herdr wait agent-state <pane_id> --state <idle|working|blocked|unknown> [--timeout MS]
```
@@ -825,11 +827,13 @@ herdr wait agent-state <pane_id> --state <idle|working|blocked|unknown> [--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(&params.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<i32> {
fn pane_read(args: &[String]) -> std::io::Result<i32> {
let Some(raw_pane_id) = args.first() else {
- eprintln!("usage: herdr pane read <pane_id> [--source visible|recent] [--lines N]");
+ eprintln!("usage: herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N]");
return Ok(2);
};
@@ -763,7 +763,7 @@ fn integration_uninstall(args: &[String]) -> std::io::Result<i32> {
fn wait_output(args: &[String]) -> std::io::Result<i32> {
let Some(raw_pane_id) = args.first() else {
- eprintln!("usage: herdr wait output <pane_id> --match <text> [--source visible|recent] [--lines N] [--timeout MS] [--regex]");
+ eprintln!("usage: herdr wait output <pane_id> --match <text> [--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<ReadSource> {
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 <workspace_id>]");
eprintln!(" herdr pane get <pane_id>");
- eprintln!(" herdr pane read <pane_id> [--source visible|recent] [--lines N]");
+ eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N]");
eprintln!(" herdr pane split <pane_id> --direction right|down [--cwd PATH] [--no-focus]");
eprintln!(" herdr pane close <pane_id>");
eprintln!(" herdr pane send-text <pane_id> <text>");
@@ -1080,7 +1081,7 @@ fn print_pane_help() {
fn print_wait_help() {
eprintln!("herdr wait commands:");
- eprintln!(" herdr wait output <pane_id> --match <text> [--source visible|recent] [--lines N] [--timeout MS] [--regex]");
+ eprintln!(" herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex]");
eprintln!(
" herdr wait agent-state <pane_id> --state <idle|working|blocked|unknown> [--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<String, Error> {
- 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<String, Error> {
+ 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<String, Error> {
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<String> {
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<String> {
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<String, crate::ghostty::Error> {
+ 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<String> {
self.terminal.extract_selection(selection)
}
@@ -1964,6 +1995,17 @@ mod tests {
}
#[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);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
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
@@ -340,6 +340,81 @@ fn pane_run_read_and_wait_commands_work() {
}
#[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();
let config_home = base.join("config");