feat: add ansi pane read format

This commit is contained in:
Ogulcan Celik 2026-05-09 22:07:33 +03:00
parent c6d8d8351e
commit 3a9888cd56
10 changed files with 260 additions and 21 deletions

View File

@ -136,6 +136,9 @@ herdr wait agent-status 1-1 --status done
# read output
herdr pane read 1-2 --source recent --lines 50
# read a rendered ANSI snapshot for TUI feedback loops
herdr pane read 1-2 --source visible --ansi
```
full reference: [`SOCKET_API.md`](./SOCKET_API.md) and [`SKILL.md`](./SKILL.md).

View File

@ -287,6 +287,7 @@ 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-status` print json on success.
- `pane read` prints text, not json.
- `pane read --format ansi` or `pane read --ansi` returns a rendered ANSI snapshot for TUI feedback loops.
- `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. `workspace create` returns `result.workspace`, `result.tab`, and `result.root_pane`. `tab create` returns `result.tab` and `result.root_pane`. for `pane split`, the new pane id is at `result.pane.pane_id`.

View File

@ -165,6 +165,7 @@ for backward compatibility, requests also accept the older positional forms like
"workspace_id": "w64e95948145ed1",
"tab_id": "w64e95948145ed1:1",
"source": "recent",
"format": "text",
"text": "...",
"revision": 0,
"truncated": false
@ -489,21 +490,24 @@ params:
"pane_id": "1-1",
"source": "recent",
"lines": 80,
"format": "text",
"strip_ansi": true
}
```
notes:
- `source` is required and must be `visible` or `recent`
- `source` is required and must be `visible`, `recent`, or `recent_unwrapped`
- `lines` is optional
- current implementation defaults to `80` lines when `lines` is omitted and caps reads at `1000`
- `strip_ansi` defaults to `true`
- `format` defaults to `text`; use `ansi` for a rendered VT/ANSI snapshot with styles preserved
- `strip_ansi` defaults to `true` and is kept for compatibility
`source` meanings:
- `visible` — current viewport
- `recent` — recent scrollback text
- `recent_unwrapped` — recent scrollback text with soft wraps joined
example response:
@ -517,6 +521,7 @@ example response:
"workspace_id": "1",
"tab_id": "1:1",
"source": "recent",
"format": "text",
"text": "...",
"revision": 0,
"truncated": false
@ -941,7 +946,7 @@ pane commands:
```text
herdr pane list [--workspace <workspace_id>]
herdr pane get <pane_id>
herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--raw]
herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]
herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]
herdr pane close <pane_id>
herdr pane send-text <pane_id> <text>
@ -971,13 +976,14 @@ herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [
- `tab create` returns `result.tab` and `result.root_pane`
- `pane split` keeps focus where it is by default; pass `--focus` to switch to the new pane
- `pane read` prints **text**, not json
- `pane read --format ansi` and `pane read --ansi` print a rendered ANSI snapshot with colors/styles preserved
- `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_input` with the command text followed by a real `Enter` keypress
- `wait agent-status` is a cli convenience built on top of event subscriptions
- use it when you want the same `done` / `idle` distinction the UI shows
- `--raw` disables ansi stripping for `pane read` and `wait output`
- `--raw` is a legacy alias for ANSI formatted `pane read` output and still disables ansi stripping for `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

@ -415,6 +415,7 @@ fn wait_for_output(
pane_id: params.pane_id.clone(),
source: output_match_read_source(&params.source),
lines: params.lines,
format: crate::api::schema::ReadFormat::Text,
strip_ansi: params.strip_ansi,
}),
};
@ -894,6 +895,7 @@ fn pane_read(
pane_id: pane_id.to_string(),
source,
lines,
format: crate::api::schema::ReadFormat::Text,
strip_ansi,
}),
},
@ -1203,6 +1205,7 @@ mod tests {
workspace_id: "ws_1".into(),
tab_id: "tab_1".into(),
source: crate::api::schema::ReadSource::RecentUnwrapped,
format: crate::api::schema::ReadFormat::Text,
text: String::new(),
revision: 0,
truncated: false,

View File

@ -187,6 +187,8 @@ pub struct PaneReadParams {
pub source: ReadSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lines: Option<u32>,
#[serde(default)]
pub format: ReadFormat,
#[serde(default = "default_true")]
pub strip_ansi: bool,
}
@ -215,7 +217,7 @@ pub struct PaneReleaseAgentParams {
pub agent: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReadSource {
Visible,
@ -223,6 +225,14 @@ pub enum ReadSource {
RecentUnwrapped,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ReadFormat {
#[default]
Text,
Ansi,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EventsSubscribeParams {
pub subscriptions: Vec<Subscription>,
@ -528,6 +538,7 @@ pub struct PaneReadResult {
pub workspace_id: String,
pub tab_id: String,
pub source: ReadSource,
pub format: ReadFormat,
pub text: String,
pub revision: u64,
pub truncated: bool,
@ -686,6 +697,7 @@ mod tests {
pane_id: "p_1".into(),
source: ReadSource::Recent,
lines: Some(80),
format: ReadFormat::Text,
strip_ansi: true,
}),
};
@ -845,6 +857,26 @@ mod tests {
assert!(params.strip_ansi);
}
#[test]
fn pane_read_defaults_to_text_format() {
let json = r#"
{
"id": "req_1",
"method": "pane.read",
"params": {
"pane_id": "p_1",
"source": "visible"
}
}
"#;
let request: Request = serde_json::from_str(json).unwrap();
let Method::PaneRead(params) = request.method else {
panic!("wrong method parsed");
};
assert_eq!(params.format, ReadFormat::Text);
}
#[test]
fn event_envelope_round_trips() {
let event = EventEnvelope {
@ -922,6 +954,7 @@ mod tests {
workspace_id: "w_1".into(),
tab_id: "t_1_1".into(),
source: ReadSource::Recent,
format: ReadFormat::Text,
text: "auth: received\n".into(),
revision: 0,
truncated: false,

View File

@ -288,8 +288,8 @@ impl App {
use crate::api::schema::{
ErrorBody, ErrorResponse, IntegrationInstallResult, IntegrationUninstallResult, Method,
PaneListParams, PaneReadResult, ReadSource, ResponseResult, SuccessResponse,
TabListParams,
PaneListParams, PaneReadResult, ReadFormat, ReadSource, ResponseResult,
SuccessResponse, TabListParams,
};
let response = match request.method {
@ -964,10 +964,17 @@ impl App {
.unwrap();
};
let requested_lines = params.lines.unwrap_or(80).min(1000) as usize;
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),
let text = match params.format {
ReadFormat::Text => match params.source {
ReadSource::Visible => pane.visible_text(),
ReadSource::Recent => pane.recent_text(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
},
ReadFormat::Ansi => match params.source {
ReadSource::Visible => pane.visible_ansi(),
ReadSource::Recent => pane.recent_ansi(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
},
};
SuccessResponse {
id: request.id,
@ -977,6 +984,7 @@ impl App {
workspace_id,
tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(),
source: params.source,
format: params.format,
text,
revision: 0,
truncated: false,

View File

@ -8,8 +8,8 @@ use crate::api;
use crate::api::schema::{
AgentStatus, EmptyParams, IntegrationTarget, Method, OutputMatch, PaneListParams,
PaneReadParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams,
PaneTarget, PaneWaitForOutputParams, PingParams, ReadSource, Request, SplitDirection,
Subscription, TabCreateParams, TabListParams, TabRenameParams, TabTarget,
PaneTarget, PaneWaitForOutputParams, PingParams, ReadFormat, ReadSource, Request,
SplitDirection, Subscription, TabCreateParams, TabListParams, TabRenameParams, TabTarget,
WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget,
};
@ -789,13 +789,14 @@ 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|recent-unwrapped] [--lines N]");
eprintln!("usage: herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]");
return Ok(2);
};
let pane_id = normalize_pane_id(raw_pane_id);
let mut source = ReadSource::Recent;
let mut lines = None;
let mut format = ReadFormat::Text;
let mut strip_ansi = true;
let mut index = 1;
@ -817,7 +818,20 @@ fn pane_read(args: &[String]) -> std::io::Result<i32> {
lines = Some(parse_u32_flag("--lines", value)?);
index += 2;
}
"--format" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --format");
return Ok(2);
};
format = parse_read_format(value)?;
index += 2;
}
"--ansi" => {
format = ReadFormat::Ansi;
index += 1;
}
"--raw" => {
format = ReadFormat::Ansi;
strip_ansi = false;
index += 1;
}
@ -834,6 +848,7 @@ fn pane_read(args: &[String]) -> std::io::Result<i32> {
pane_id,
source,
lines,
format,
strip_ansi,
}),
})?;
@ -1308,6 +1323,16 @@ fn parse_read_source(value: &str) -> std::io::Result<ReadSource> {
}
}
fn parse_read_format(value: &str) -> std::io::Result<ReadFormat> {
match value {
"text" => Ok(ReadFormat::Text),
"ansi" => Ok(ReadFormat::Ansi),
_ => Err(std::io::Error::other(format!(
"invalid read format: {value}"
))),
}
}
fn parse_agent_status(value: &str) -> std::io::Result<AgentStatus> {
match value {
"idle" => Ok(AgentStatus::Idle),
@ -1438,7 +1463,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|recent-unwrapped] [--lines N] [--raw]");
eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]");
eprintln!(
" herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]"
);

View File

@ -23,6 +23,21 @@ pub use bindings as ffi;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Error(ffi::GhosttyResult);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FormatterFormat {
Plain,
Vt,
}
impl FormatterFormat {
fn as_raw(self) -> ffi::GhosttyFormatterFormat {
match self {
Self::Plain => ffi::GhosttyFormatterFormat_GHOSTTY_FORMATTER_FORMAT_PLAIN,
Self::Vt => ffi::GhosttyFormatterFormat_GHOSTTY_FORMATTER_FORMAT_VT,
}
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ghostty error {}", self.0)
@ -427,10 +442,29 @@ impl Terminal {
end: (u16, u32),
rectangle: bool,
) -> Result<String, Error> {
self.read_text_selection(
self.read_formatted_selection(
ghostty_viewport_point(start.0, start.1),
ghostty_viewport_point(end.0, end.1),
rectangle,
FormatterFormat::Plain,
true,
true,
)
}
pub fn read_ansi_viewport(
&self,
start: (u16, u32),
end: (u16, u32),
rectangle: bool,
) -> Result<String, Error> {
self.read_formatted_selection(
ghostty_viewport_point(start.0, start.1),
ghostty_viewport_point(end.0, end.1),
rectangle,
FormatterFormat::Vt,
false,
true,
)
}
@ -440,18 +474,41 @@ impl Terminal {
end: (u16, u32),
rectangle: bool,
) -> Result<String, Error> {
self.read_text_selection(
self.read_formatted_selection(
ghostty_screen_point(start.0, start.1),
ghostty_screen_point(end.0, end.1),
rectangle,
FormatterFormat::Plain,
true,
true,
)
}
fn read_text_selection(
pub fn read_ansi_screen(
&self,
start: (u16, u32),
end: (u16, u32),
rectangle: bool,
unwrap: bool,
) -> Result<String, Error> {
self.read_formatted_selection(
ghostty_screen_point(start.0, start.1),
ghostty_screen_point(end.0, end.1),
rectangle,
FormatterFormat::Vt,
unwrap,
true,
)
}
fn read_formatted_selection(
&self,
start: ffi::GhosttyPoint,
end: ffi::GhosttyPoint,
rectangle: bool,
format: FormatterFormat,
unwrap: bool,
trim: bool,
) -> Result<String, Error> {
let mut start_ref = ffi::GhosttyGridRef {
size: mem::size_of::<ffi::GhosttyGridRef>(),
@ -475,9 +532,9 @@ impl Terminal {
let mut formatter: ffi::GhosttyFormatter_ptr = ptr::null_mut();
let options = ffi::GhosttyFormatterTerminalOptions {
size: mem::size_of::<ffi::GhosttyFormatterTerminalOptions>(),
emit: ffi::GhosttyFormatterFormat_GHOSTTY_FORMATTER_FORMAT_PLAIN,
unwrap: true,
trim: true,
emit: format.as_raw(),
unwrap,
trim,
extra: ffi::GhosttyFormatterTerminalExtra {
size: mem::size_of::<ffi::GhosttyFormatterTerminalExtra>(),
screen: ffi::GhosttyFormatterScreenExtra {

View File

@ -761,14 +761,26 @@ impl PaneRuntime {
self.terminal.visible_text()
}
pub fn visible_ansi(&self) -> String {
self.terminal.visible_ansi()
}
pub fn recent_text(&self, lines: usize) -> String {
self.terminal.recent_text(lines)
}
pub fn recent_ansi(&self, lines: usize) -> String {
self.terminal.recent_ansi(lines)
}
pub fn recent_unwrapped_text(&self, lines: usize) -> String {
self.terminal.recent_unwrapped_text(lines)
}
pub fn recent_unwrapped_ansi(&self, lines: usize) -> String {
self.terminal.recent_unwrapped_ansi(lines)
}
pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> {
self.terminal.extract_selection(selection)
}

View File

@ -133,6 +133,10 @@ impl PaneTerminal {
self.ghostty.visible_text()
}
pub fn visible_ansi(&self) -> String {
self.ghostty.visible_ansi()
}
pub fn detection_text(&self) -> String {
self.ghostty.detection_text()
}
@ -141,10 +145,18 @@ impl PaneTerminal {
self.ghostty.recent_text(lines)
}
pub fn recent_ansi(&self, lines: usize) -> String {
self.ghostty.recent_ansi(lines)
}
pub fn recent_unwrapped_text(&self, lines: usize) -> String {
self.ghostty.recent_unwrapped_text(lines)
}
pub fn recent_unwrapped_ansi(&self, lines: usize) -> String {
self.ghostty.recent_unwrapped_ansi(lines)
}
pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> {
self.ghostty.extract_selection(selection)
}
@ -546,6 +558,14 @@ impl GhosttyPaneTerminal {
.unwrap_or_default()
}
pub fn visible_ansi(&self) -> String {
self.core
.lock()
.ok()
.and_then(|core| ghostty_visible_ansi(&core).ok())
.unwrap_or_default()
}
pub fn detection_text(&self) -> String {
self.core
.lock()
@ -562,6 +582,14 @@ impl GhosttyPaneTerminal {
.unwrap_or_default()
}
pub fn recent_ansi(&self, lines: usize) -> String {
self.core
.lock()
.ok()
.and_then(|core| ghostty_recent_ansi(&core, lines, false).ok())
.unwrap_or_default()
}
pub fn recent_unwrapped_text(&self, lines: usize) -> String {
self.core
.lock()
@ -570,6 +598,14 @@ impl GhosttyPaneTerminal {
.unwrap_or_default()
}
pub fn recent_unwrapped_ansi(&self, lines: usize) -> String {
self.core
.lock()
.ok()
.and_then(|core| ghostty_recent_ansi(&core, lines, true).ok())
.unwrap_or_default()
}
pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> {
self.core
.lock()
@ -726,6 +762,19 @@ fn ghostty_visible_text(core: &mut GhosttyPaneCore) -> Result<String, crate::gho
Ok(lines_to_text(lines))
}
fn ghostty_visible_ansi(core: &GhosttyPaneCore) -> Result<String, crate::ghostty::Error> {
let rows = core.terminal.rows()?;
let cols = core.terminal.cols()?;
if rows == 0 || cols == 0 {
return Ok(String::new());
}
core.terminal.read_ansi_viewport(
(0, 0),
(cols.saturating_sub(1), u32::from(rows.saturating_sub(1))),
false,
)
}
fn ghostty_detection_text(core: &GhosttyPaneCore) -> Result<String, crate::ghostty::Error> {
let lines = core
.terminal
@ -766,6 +815,22 @@ fn ghostty_recent_text_unwrapped(
.read_text_screen((0, start), (cols.saturating_sub(1), end), false)
}
fn ghostty_recent_ansi(
core: &GhosttyPaneCore,
lines: usize,
unwrap: bool,
) -> 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_ansi_screen((0, start), (cols.saturating_sub(1), end), false, unwrap)
}
fn ghostty_extract_selection(
core: &mut GhosttyPaneCore,
selection: &crate::selection::Selection,
@ -1456,6 +1521,32 @@ mod tests {
assert_eq!(pane.recent_unwrapped_text(3), "ABCDEFGHIJ");
}
#[test]
fn visible_ansi_preserves_cell_style_sequences() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap();
terminal.write(b"\x1b[31;1mred\x1b[0m plain");
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let ansi = pane.visible_ansi();
assert!(ansi.contains("red"));
assert!(ansi.contains("plain"));
assert!(ansi.contains("\x1b["));
}
#[test]
fn recent_ansi_can_read_styled_scrollback() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap();
terminal.write(b"\x1b[34mblue\x1b[0m\r\nline2\r\nline3\r\nline4");
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let ansi = pane.recent_ansi(4);
assert!(ansi.contains("blue"));
assert!(ansi.contains("line4"));
assert!(ansi.contains("\x1b["));
}
#[test]
fn resize_reflow_keeps_scrolled_viewport_and_bottom_detection_sane() {
let (tx, _rx) = mpsc::channel(4);