use portable_pty::PtySize;
use std::sync::OnceLock;
use tracing::warn;
use vt100::Parser;
use vtcode_ghostty_core::Terminal;
use vtcode_ghostty_vt_sys::{GhosttyRenderRequest, render_terminal_snapshot};
use crate::config::PtyEmulationBackend;
pub(super) trait PtyBackend: Send {
fn process(&mut self, chunk: &[u8]);
fn resize(&mut self, rows: u16, cols: u16);
fn screen_text(&self) -> String;
fn scrollback_text(&self) -> String;
}
struct GhosttyCoreBackend {
terminal: Terminal,
}
impl GhosttyCoreBackend {
fn new(size: PtySize, max_scrollback: usize) -> Self {
let mut terminal = Terminal::new(size.cols as usize, size.rows as usize);
terminal.set_max_scrollback(max_scrollback);
Self { terminal }
}
}
impl PtyBackend for GhosttyCoreBackend {
fn process(&mut self, chunk: &[u8]) {
self.terminal.write(chunk);
}
fn resize(&mut self, rows: u16, cols: u16) {
self.terminal.resize(cols as usize, rows as usize);
}
fn screen_text(&self) -> String {
self.terminal.plain_text()
}
fn scrollback_text(&self) -> String {
let mut out = String::new();
for i in 0..self.terminal.scrollback_len() {
if let Some(row) = self.terminal.scrollback_row(i) {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&row);
}
}
out
}
}
struct GhosttyFfiBackend {
vt100_parser: Parser,
raw_vt_buffer: super::raw_vt_buffer::RawVtBuffer,
scrollback_lines: usize,
size: PtySize,
}
impl GhosttyFfiBackend {
fn new(size: PtySize, scrollback_lines: usize, max_scrollback_bytes: usize) -> Self {
Self {
vt100_parser: Parser::new(size.rows, size.cols, scrollback_lines),
raw_vt_buffer: super::raw_vt_buffer::RawVtBuffer::new(max_scrollback_bytes),
scrollback_lines,
size,
}
}
}
impl PtyBackend for GhosttyFfiBackend {
fn process(&mut self, chunk: &[u8]) {
self.vt100_parser.process(chunk);
self.raw_vt_buffer.push(chunk);
}
fn resize(&mut self, rows: u16, cols: u16) {
self.vt100_parser.screen_mut().set_size(rows, cols);
self.size = PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
};
}
fn screen_text(&self) -> String {
let snapshot = self.raw_vt_buffer.snapshot();
if snapshot.was_truncated {
return self.vt100_parser.screen().contents();
}
match render_terminal_snapshot(
GhosttyRenderRequest {
cols: self.size.cols,
rows: self.size.rows,
scrollback_lines: self.scrollback_lines,
},
&snapshot.bytes,
) {
Ok(output) => output.screen_contents,
Err(error) => {
warn_ghostty_fallback_once(PtyEmulationBackend::Ghostty, &error);
self.vt100_parser.screen().contents()
}
}
}
fn scrollback_text(&self) -> String {
self.vt100_parser.screen().contents()
}
}
struct Vt100Backend {
parser: Parser,
}
impl Vt100Backend {
fn new(size: PtySize, scrollback_lines: usize) -> Self {
Self {
parser: Parser::new(size.rows, size.cols, scrollback_lines),
}
}
}
impl PtyBackend for Vt100Backend {
fn process(&mut self, chunk: &[u8]) {
self.parser.process(chunk);
}
fn resize(&mut self, rows: u16, cols: u16) {
self.parser.screen_mut().set_size(rows, cols);
}
fn screen_text(&self) -> String {
self.parser.screen().contents()
}
fn scrollback_text(&self) -> String {
self.parser.screen().contents()
}
}
pub(super) struct PtyScreenState {
backend: Box<dyn PtyBackend>,
}
impl PtyScreenState {
pub(super) fn new(
size: PtySize,
scrollback_lines: usize,
backend: PtyEmulationBackend,
max_scrollback_bytes: usize,
) -> Self {
let backend: Box<dyn PtyBackend> = match backend {
PtyEmulationBackend::Ghostty => Box::new(GhosttyFfiBackend::new(
size,
scrollback_lines,
max_scrollback_bytes,
)),
PtyEmulationBackend::GhosttyCore => {
Box::new(GhosttyCoreBackend::new(size, scrollback_lines))
}
PtyEmulationBackend::LegacyVt100 => Box::new(Vt100Backend::new(size, scrollback_lines)),
};
Self { backend }
}
pub(super) fn process(&mut self, chunk: &[u8]) {
self.backend.process(chunk);
}
pub(super) fn resize(&mut self, size: PtySize) {
self.backend.resize(size.rows, size.cols);
}
pub(super) fn prepare_snapshot(&self) -> ScreenSnapshot {
ScreenSnapshot {
screen_contents: self.backend.screen_text(),
scrollback: self.backend.scrollback_text(),
}
}
}
pub(super) struct ScreenSnapshot {
pub(super) screen_contents: String,
pub(super) scrollback: String,
}
fn warn_ghostty_fallback_once(configured_backend: PtyEmulationBackend, error: &anyhow::Error) {
static WARNED: OnceLock<()> = OnceLock::new();
if WARNED.set(()).is_ok() {
warn!(
configured_backend = configured_backend.as_str(),
active_backend = PtyEmulationBackend::LegacyVt100.as_str(),
error = %error,
"PTY snapshot backend resolved via fallback"
);
}
}
#[cfg(test)]
mod tests {
use portable_pty::PtySize;
use super::PtyScreenState;
use crate::config::PtyEmulationBackend;
fn test_size() -> PtySize {
PtySize {
rows: 4,
cols: 10,
pixel_width: 0,
pixel_height: 0,
}
}
const MAX_SCROLLBACK_BYTES: usize = 25_000_000;
#[test]
fn ghostty_core_backend_processes_plain_text() {
let size = test_size();
let mut state = PtyScreenState::new(
size,
100,
PtyEmulationBackend::GhosttyCore,
MAX_SCROLLBACK_BYTES,
);
state.process(b"hello");
let snapshot = state.prepare_snapshot();
assert!(
snapshot.screen_contents.contains("hello"),
"screen_contents = {:?}",
snapshot.screen_contents
);
}
#[test]
fn legacy_backend_uses_legacy_snapshot() {
let size = test_size();
let mut state = PtyScreenState::new(
size,
100,
PtyEmulationBackend::LegacyVt100,
MAX_SCROLLBACK_BYTES,
);
state.process(b"legacy");
let snapshot = state.prepare_snapshot();
assert!(
snapshot.screen_contents.contains("legacy"),
"screen_contents = {:?}",
snapshot.screen_contents
);
}
#[test]
fn ghostty_core_backend_handles_newlines() {
let size = test_size();
let mut state = PtyScreenState::new(
size,
100,
PtyEmulationBackend::GhosttyCore,
MAX_SCROLLBACK_BYTES,
);
state.process(b"line1\nline2");
let snapshot = state.prepare_snapshot();
assert!(
snapshot.screen_contents.contains("line1"),
"screen_contents = {:?}",
snapshot.screen_contents
);
assert!(
snapshot.screen_contents.contains("line2"),
"screen_contents = {:?}",
snapshot.screen_contents
);
}
#[test]
fn ghostty_core_backend_handles_ansi_colors() {
let size = test_size();
let mut state = PtyScreenState::new(
size,
100,
PtyEmulationBackend::GhosttyCore,
MAX_SCROLLBACK_BYTES,
);
state.process(b"\x1B[1;31mcolored\x1B[0m");
let snapshot = state.prepare_snapshot();
assert!(
snapshot.screen_contents.contains("colored"),
"screen_contents = {:?}",
snapshot.screen_contents
);
}
}