aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-09 22:07:33 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-09 22:07:52 +0300
commit3a9888cd564dc45d23a7bdd0dba29a6ff1ef48a1 (patch)
tree19a36f33ee5813220f70d7df7805c71c3b21f591
parentc6d8d8351e67a183efda3aae4dc0986e9b1e9a48 (diff)
feat: add ansi pane read format
-rw-r--r--README.md3
-rw-r--r--SKILL.md1
-rw-r--r--SOCKET_API.md14
-rw-r--r--src/api/mod.rs3
-rw-r--r--src/api/schema.rs35
-rw-r--r--src/app/api.rs20
-rw-r--r--src/cli.rs33
-rw-r--r--src/ghostty/mod.rs69
-rw-r--r--src/pane.rs12
-rw-r--r--src/pane/terminal.rs91
10 files changed, 260 insertions, 21 deletions
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 <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
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(&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,
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,
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<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();