docs: clarify herdr pane split and wait output usage

This commit is contained in:
Ogulcan Celik 2026-04-07 02:48:13 +03:00
parent 06f01df93d
commit 5e05460816
9 changed files with 203 additions and 31 deletions

View File

@ -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`.

View File

@ -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

View File

@ -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,

View File

@ -197,6 +197,7 @@ pub struct PaneReleaseAgentParams {
pub enum ReadSource {
Visible,
Recent,
RecentUnwrapped,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View File

@ -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,

View File

@ -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]"
);

View File

@ -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,
}

View File

@ -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)
}
@ -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);

View File

@ -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();