docs: clarify herdr pane split and wait output usage
This commit is contained in:
parent
06f01df93d
commit
5e05460816
50
SKILL.md
50
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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ pub struct PaneReleaseAgentParams {
|
|||
pub enum ReadSource {
|
||||
Visible,
|
||||
Recent,
|
||||
RecentUnwrapped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
42
src/pane.rs
42
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)
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue