diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-09 22:07:33 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-09 22:07:52 +0300 |
| commit | 3a9888cd564dc45d23a7bdd0dba29a6ff1ef48a1 (patch) | |
| tree | 19a36f33ee5813220f70d7df7805c71c3b21f591 /src | |
| parent | c6d8d8351e67a183efda3aae4dc0986e9b1e9a48 (diff) | |
feat: add ansi pane read format
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 3 | ||||
| -rw-r--r-- | src/api/schema.rs | 35 | ||||
| -rw-r--r-- | src/app/api.rs | 20 | ||||
| -rw-r--r-- | src/cli.rs | 33 | ||||
| -rw-r--r-- | src/ghostty/mod.rs | 69 | ||||
| -rw-r--r-- | src/pane.rs | 12 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 91 |
7 files changed, 246 insertions, 17 deletions
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<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, }), }; @@ -846,6 +858,26 @@ mod tests { } #[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 { event: EventKind::PaneOutputChanged, @@ -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, @@ -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]" ); 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<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, + ) + } + + 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_text_selection( + 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 { 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<String> { 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<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, @@ -1457,6 +1522,32 @@ mod tests { } #[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); let mut terminal = crate::ghostty::Terminal::new(12, 4, 10_000).unwrap(); |
