diff --git a/README.md b/README.md index 045b7aaa..efeeb85f 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/SKILL.md b/SKILL.md index d2ada62d..8af56cd1 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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`. diff --git a/SOCKET_API.md b/SOCKET_API.md index dbb2c379..86b319a6 100644 --- a/SOCKET_API.md +++ b/SOCKET_API.md @@ -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 ] herdr pane get -herdr pane read [--source visible|recent|recent-unwrapped] [--lines N] [--raw] +herdr pane read [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi] herdr pane split --direction right|down [--cwd PATH] [--focus] [--no-focus] herdr pane close herdr pane send-text @@ -971,13 +976,14 @@ herdr wait agent-status --status [ - `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 diff --git a/src/api/mod.rs b/src/api/mod.rs index ff53e6ad..f256e4a8 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -415,6 +415,7 @@ fn wait_for_output( pane_id: params.pane_id.clone(), source: output_match_read_source(¶ms.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, diff --git a/src/api/schema.rs b/src/api/schema.rs index fc3cc68f..e7534465 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -187,6 +187,8 @@ pub struct PaneReadParams { pub source: ReadSource, #[serde(default, skip_serializing_if = "Option::is_none")] pub lines: Option, + #[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, @@ -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, diff --git a/src/app/api.rs b/src/app/api.rs index bda7fc16..b23787d2 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -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, diff --git a/src/cli.rs b/src/cli.rs index dcc2537c..1d3c29e2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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 { fn pane_read(args: &[String]) -> std::io::Result { let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane read [--source visible|recent|recent-unwrapped] [--lines N]"); + eprintln!("usage: herdr pane read [--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 { 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 { pane_id, source, lines, + format, strip_ansi, }), })?; @@ -1308,6 +1323,16 @@ fn parse_read_source(value: &str) -> std::io::Result { } } +fn parse_read_format(value: &str) -> std::io::Result { + 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 { match value { "idle" => Ok(AgentStatus::Idle), @@ -1438,7 +1463,7 @@ fn print_pane_help() { eprintln!("herdr pane commands:"); eprintln!(" herdr pane list [--workspace ]"); eprintln!(" herdr pane get "); - eprintln!(" herdr pane read [--source visible|recent|recent-unwrapped] [--lines N] [--raw]"); + eprintln!(" herdr pane read [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); eprintln!( " herdr pane split --direction right|down [--cwd PATH] [--focus] [--no-focus]" ); diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 60ca785d..27fcb6f9 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -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 { - 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 { + 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 { - 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 { + 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 { let mut start_ref = ffi::GhosttyGridRef { size: mem::size_of::(), @@ -475,9 +532,9 @@ impl Terminal { let mut formatter: ffi::GhosttyFormatter_ptr = ptr::null_mut(); let options = ffi::GhosttyFormatterTerminalOptions { size: mem::size_of::(), - emit: ffi::GhosttyFormatterFormat_GHOSTTY_FORMATTER_FORMAT_PLAIN, - unwrap: true, - trim: true, + emit: format.as_raw(), + unwrap, + trim, extra: ffi::GhosttyFormatterTerminalExtra { size: mem::size_of::(), screen: ffi::GhosttyFormatterScreenExtra { diff --git a/src/pane.rs b/src/pane.rs index da90420e..c741c04d 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -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 { self.terminal.extract_selection(selection) } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index fcda5fa6..80f83915 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -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 { 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 { self.core .lock() @@ -726,6 +762,19 @@ fn ghostty_visible_text(core: &mut GhosttyPaneCore) -> Result Result { + 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 { 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 { + 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);