aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-07 15:09:09 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-07 15:09:09 +0300
commit0276da6f2a44da493d1c198bcc6b5a8b1d2fda44 (patch)
tree4362a0c52fa42097d2c6fabc8212e4d01ddd459c
parent78fc0721a3d014fe96e778c6bb452cfdfbe67603 (diff)
fix: make pane run submit commands atomically
-rw-r--r--SKILL.md2
-rw-r--r--SOCKET_API.md4
-rw-r--r--src/cli.rs15
-rw-r--r--tests/cli_wrapper.rs68
4 files changed, 72 insertions, 17 deletions
diff --git a/SKILL.md b/SKILL.md
index 599529ff..be72d022 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -165,7 +165,7 @@ press Enter or other keys:
herdr pane send-keys 1-1 Enter
```
-`pane run` combines both — sends text, then presses Enter:
+`pane run` appends a trailing carriage return (`\r`) to the text so the command executes:
```bash
herdr pane run 1-1 "echo hello"
diff --git a/SOCKET_API.md b/SOCKET_API.md
index c145aa53..ad5a80e7 100644
--- a/SOCKET_API.md
+++ b/SOCKET_API.md
@@ -830,7 +830,7 @@ herdr wait agent-state <pane_id> --state <idle|working|blocked|unknown> [--timeo
- `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`
+- `pane run` is a convenience wrapper for `pane send-text` with a trailing carriage return (`\r`)
- `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
@@ -860,7 +860,7 @@ herdr pane read 1-1 --source recent --lines 80
## behavior notes and gotchas
-- `pane.send_text` sends literal text only. if you want to execute a command, follow it with `pane.send_keys` and `Enter`, or use cli `pane run`.
+- `pane.send_text` sends literal text only. if you want to execute a command, follow it with `pane.send_keys` and `Enter`, or use cli `pane run`, which appends a trailing carriage return (`\r`).
- `pane.read` and `pane.wait_for_output` strip ansi by default.
- `pane.output_matched` subscriptions fire on transitions into a matching state; they do not repeatedly spam the same still-visible match on every poll.
- closing the socket connection ends the subscription.
diff --git a/src/cli.rs b/src/cli.rs
index cc364051..e5399eca 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -596,19 +596,8 @@ fn pane_run(args: &[String]) -> std::io::Result<i32> {
}
let pane_id = normalize_pane_id(&args[0]);
- let text = args[1..].join(" ");
- let send_text_status = send_ok_request(Method::PaneSendText(PaneSendTextParams {
- pane_id: pane_id.clone(),
- text,
- }))?;
- if send_text_status != 0 {
- return Ok(send_text_status);
- }
-
- send_ok_request(Method::PaneSendKeys(PaneSendKeysParams {
- pane_id,
- keys: vec!["Enter".into()],
- }))
+ let text = format!("{}\r", args[1..].join(" "));
+ send_ok_request(Method::PaneSendText(PaneSendTextParams { pane_id, text }))
}
fn integration_install(args: &[String]) -> std::io::Result<i32> {
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index dc091e80..97d309a4 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -2,7 +2,7 @@
use std::fs;
use std::io::{BufRead, BufReader, Write};
-use std::os::unix::net::UnixStream;
+use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
@@ -126,6 +126,72 @@ fn send_request(socket_path: &Path, json: &str) -> serde_json::Value {
}
#[test]
+fn pane_run_sends_one_send_text_request_with_trailing_carriage_return() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let socket_path = base.join("herdr.sock");
+ let listener = UnixListener::bind(&socket_path).unwrap();
+
+ let server = thread::spawn(move || {
+ let (mut first_stream, _) = listener.accept().unwrap();
+ let mut first_line = String::new();
+ let mut first_reader = BufReader::new(first_stream.try_clone().unwrap());
+ first_reader.read_line(&mut first_line).unwrap();
+ first_stream
+ .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
+ .unwrap();
+ first_stream.write_all(b"\n").unwrap();
+ first_stream.flush().unwrap();
+
+ let mut second_line = None;
+ listener.set_nonblocking(true).unwrap();
+ let deadline = Instant::now() + Duration::from_millis(250);
+ while Instant::now() < deadline {
+ match listener.accept() {
+ Ok((mut second_stream, _)) => {
+ let mut line = String::new();
+ let mut reader = BufReader::new(second_stream.try_clone().unwrap());
+ reader.read_line(&mut line).unwrap();
+ second_stream
+ .write_all(br#"{"id":"cli:request","result":{"type":"ok"}}"#)
+ .unwrap();
+ second_stream.write_all(b"\n").unwrap();
+ second_stream.flush().unwrap();
+ second_line = Some(line);
+ break;
+ }
+ Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
+ thread::sleep(Duration::from_millis(10));
+ }
+ Err(err) => panic!("second accept failed: {err}"),
+ }
+ }
+
+ (first_line, second_line)
+ });
+
+ let run = run_cli(&socket_path, &["pane", "run", "1-1", "echo hello"]);
+ assert!(
+ run.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&run.stderr)
+ );
+
+ let (first_line, second_line) = server.join().unwrap();
+ let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap();
+ assert_eq!(first_request["method"], "pane.send_text");
+ assert_eq!(first_request["params"]["pane_id"], "1-1");
+ assert_eq!(first_request["params"]["text"], "echo hello\r");
+ assert!(
+ second_line.is_none(),
+ "pane run sent an unexpected second request: {:?}",
+ second_line
+ );
+
+ fs::remove_dir_all(base).unwrap();
+}
+
+#[test]
fn workspace_and_pane_management_commands_work() {
let base = unique_test_dir();
let config_home = base.join("config");