use std::io::{Read, Write};
use std::path::Path;
use std::sync::{Arc, Mutex};
use alacritty_terminal::event::{Event as TermEvent, EventListener};
use alacritty_terminal::grid::{Dimensions, Scroll};
use alacritty_terminal::index::Point;
use alacritty_terminal::term::cell::{Cell, Flags};
use alacritty_terminal::term::{Config as TermConfig, Term, TermMode};
use alacritty_terminal::vte::ansi::{
Color as AnsiColor, CursorShape as TermCursorShape, Processor,
};
use anyhow::Result;
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use crate::agent::AgentSpec;
use crate::event::AppEvent;
const TERM_ENV: &str = "xterm-256color";
type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;
#[derive(Debug, Clone, Copy)]
struct TermSize {
cols: usize,
screen_lines: usize,
}
impl Dimensions for TermSize {
fn total_lines(&self) -> usize {
self.screen_lines
}
fn screen_lines(&self) -> usize {
self.screen_lines
}
fn columns(&self) -> usize {
self.cols
}
}
#[derive(Clone)]
struct AgentEventListener {
writer: SharedWriter,
title: Arc<Mutex<Option<String>>>,
}
impl EventListener for AgentEventListener {
fn send_event(&self, event: TermEvent) {
match event {
TermEvent::PtyWrite(text) => {
if let Ok(mut w) = self.writer.lock() {
let _ = w.write_all(text.as_bytes());
let _ = w.flush();
}
}
TermEvent::Title(title) => {
if let Ok(mut t) = self.title.lock() {
*t = Some(title);
}
}
TermEvent::ResetTitle => {
if let Ok(mut t) = self.title.lock() {
*t = None;
}
}
_ => {}
}
}
}
struct Emu {
processor: Processor,
term: Term<AgentEventListener>,
size: TermSize,
}
pub struct GridCell {
pub row: usize,
pub col: usize,
pub c: char,
pub fg: AnsiColor,
pub bg: AnsiColor,
pub flags: Flags,
}
pub struct GridSnapshot {
pub cols: usize,
pub rows: usize,
pub cells: Vec<GridCell>,
pub cursor_row: usize,
pub cursor_col: usize,
}
pub struct AgentSession {
pub name: String,
master: Box<dyn MasterPty + Send>,
writer: SharedWriter,
child: Box<dyn portable_pty::Child + Send + Sync>,
emu: Mutex<Emu>,
title: Arc<Mutex<Option<String>>>,
}
impl AgentSession {
pub fn spawn(
spec: &AgentSpec,
cwd: &Path,
event_tx: std::sync::mpsc::Sender<AppEvent>,
) -> Result<Self> {
const INIT_COLS: u16 = 80;
const INIT_ROWS: u16 = 24;
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize {
rows: INIT_ROWS,
cols: INIT_COLS,
pixel_width: 0,
pixel_height: 0,
})?;
let mut cmd = CommandBuilder::new(&spec.command);
for a in &spec.args {
cmd.arg(a);
}
cmd.cwd(cwd);
cmd.env("TERM", TERM_ENV);
cmd.env("COLORTERM", "truecolor");
let child = pair.slave.spawn_command(cmd)?;
drop(pair.slave);
let mut reader = pair.master.try_clone_reader()?;
let writer: SharedWriter = Arc::new(Mutex::new(pair.master.take_writer()?));
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if event_tx
.send(AppEvent::AgentOutput(buf[..n].to_vec()))
.is_err()
{
return;
}
}
Err(_) => break,
}
}
let _ = event_tx.send(AppEvent::AgentExited);
});
let title = Arc::new(Mutex::new(None));
let listener = AgentEventListener {
writer: Arc::clone(&writer),
title: Arc::clone(&title),
};
let size = TermSize {
cols: INIT_COLS as usize,
screen_lines: INIT_ROWS as usize,
};
let term = Term::new(TermConfig::default(), &size, listener);
let emu = Mutex::new(Emu {
processor: Processor::new(),
term,
size,
});
Ok(Self {
name: spec.name.clone(),
master: pair.master,
writer,
child,
emu,
title,
})
}
pub fn write(&self, bytes: &[u8]) {
if let Ok(mut w) = self.writer.lock() {
let _ = w.write_all(bytes);
let _ = w.flush();
}
}
pub fn push_output(&self, chunk: &[u8]) {
if let Ok(mut emu) = self.emu.lock() {
let Emu {
processor, term, ..
} = &mut *emu;
processor.advance(term, chunk);
}
}
pub fn scroll(&self, delta: i32) {
if let Ok(mut emu) = self.emu.lock() {
emu.term.scroll_display(Scroll::Delta(delta));
}
}
pub fn scroll_to_bottom(&self) {
if let Ok(mut emu) = self.emu.lock() {
emu.term.scroll_display(Scroll::Bottom);
}
}
pub fn term_mode(&self) -> TermMode {
self.emu
.lock()
.map(|e| *e.term.mode())
.unwrap_or_else(|_| TermMode::empty())
}
pub fn grid_snapshot(&self) -> Option<GridSnapshot> {
let emu = self.emu.lock().ok()?;
let content = emu.term.renderable_content();
let cols = emu.size.cols;
let rows = emu.size.screen_lines;
let display_offset = content.display_offset as i32;
let mut cells: Vec<GridCell> = Vec::new();
for indexed in content.display_iter {
let point: Point = indexed.point;
let row = point.line.0 + display_offset;
if row < 0 {
continue;
}
let row = row as usize;
let col = point.column.0;
if row >= rows || col >= cols {
continue;
}
let cell: &Cell = indexed.cell;
if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
continue;
}
cells.push(GridCell {
row,
col,
c: cell.c,
fg: cell.fg,
bg: cell.bg,
flags: cell.flags,
});
}
let cursor = content.cursor;
let cursor_row = (cursor.point.line.0 + display_offset).max(0) as usize;
let cursor_col = cursor.point.column.0;
Some(GridSnapshot {
cols,
rows,
cells,
cursor_row,
cursor_col,
})
}
pub fn title(&self) -> Option<String> {
self.title.lock().ok().and_then(|t| t.clone())
}
pub fn cursor_shape(&self) -> crate::config::CursorShape {
use crate::config::CursorShape;
let shape = self
.emu
.lock()
.ok()
.map(|e| e.term.cursor_style().shape)
.unwrap_or(TermCursorShape::Block);
match shape {
TermCursorShape::Beam => CursorShape::Bar,
TermCursorShape::Underline => CursorShape::Underbar,
_ => CursorShape::Block,
}
}
pub fn resize(&self, cols: u16, rows: u16) {
let cols = cols.max(1);
let rows = rows.max(1);
if let Ok(mut emu) = self.emu.lock() {
if emu.size.cols == cols as usize && emu.size.screen_lines == rows as usize {
return;
}
let size = TermSize {
cols: cols as usize,
screen_lines: rows as usize,
};
emu.term.resize(size);
emu.size = size;
}
let _ = self.master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
pub fn kill(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Drop for AgentSession {
fn drop(&mut self) {
self.kill();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::AgentSpec;
use std::time::Duration;
fn cat_spec() -> AgentSpec {
AgentSpec {
name: "cat".into(),
command: "cat".into(),
args: vec![],
prompt_args: vec![],
}
}
#[cfg(unix)]
#[test]
fn spawn_streams_pty_echo_and_kills_cleanly() {
let (tx, rx) = std::sync::mpsc::channel();
let cwd = std::env::current_dir().unwrap();
let mut session =
AgentSession::spawn(&cat_spec(), &cwd, tx).expect("cat spawns under a pty");
session.write(b"vorto-marker\n");
let mut saw_marker = false;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(AppEvent::AgentOutput(bytes)) => {
session.push_output(&bytes);
let snap = session.grid_snapshot().unwrap();
let text: String = grid_text(&snap);
if text.contains("vorto-marker") {
saw_marker = true;
break;
}
}
Ok(AppEvent::AgentExited) => break,
Ok(_) => {}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(_) => break,
}
}
assert!(
saw_marker,
"expected the PTY to echo our marker into the grid"
);
session.kill();
session.kill();
}
fn grid_text(snap: &GridSnapshot) -> String {
let mut rows = vec![vec![' '; snap.cols]; snap.rows];
for cell in &snap.cells {
if cell.row < snap.rows && cell.col < snap.cols {
rows[cell.row][cell.col] = cell.c;
}
}
rows.into_iter()
.map(|r| r.into_iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn parser_writes_text_into_the_grid() {
let (tx, _rx) = std::sync::mpsc::channel();
let cwd = std::env::current_dir().unwrap();
let Ok(mut session) = AgentSession::spawn(&cat_spec(), &cwd, tx) else {
return;
};
session.resize(20, 5);
session.push_output(b"hi\x1b[2;3HX");
let snap = session.grid_snapshot().unwrap();
let text = grid_text(&snap);
let lines: Vec<&str> = text.lines().collect();
assert!(lines[0].starts_with("hi"), "row 0 = {:?}", lines[0]);
assert_eq!(lines[1].as_bytes()[2], b'X', "row 1 = {:?}", lines[1]);
session.kill();
}
#[test]
fn grid_cursor_is_reported_even_when_the_agent_hides_it() {
let (tx, _rx) = std::sync::mpsc::channel();
let cwd = std::env::current_dir().unwrap();
let Ok(mut session) = AgentSession::spawn(&cat_spec(), &cwd, tx) else {
return;
};
session.resize(20, 5);
session.push_output(b"\x1b[2;3HX\x1b[?25l");
let snap = session.grid_snapshot().unwrap();
assert_eq!(snap.cursor_row, 1, "cursor row tracked through ?25l");
assert_eq!(snap.cursor_col, 3, "cursor col tracked through ?25l");
session.kill();
}
#[test]
fn sgr_red_foreground_lands_on_the_cell() {
let (tx, _rx) = std::sync::mpsc::channel();
let cwd = std::env::current_dir().unwrap();
let Ok(mut session) = AgentSession::spawn(&cat_spec(), &cwd, tx) else {
return;
};
session.resize(20, 3);
session.push_output(b"\x1b[31mR\x1b[0m");
let snap = session.grid_snapshot().unwrap();
let r = snap
.cells
.iter()
.find(|c| c.c == 'R')
.expect("R cell present");
assert_eq!(
r.fg,
AnsiColor::Named(alacritty_terminal::vte::ansi::NamedColor::Red)
);
session.kill();
}
#[test]
fn scroll_display_walks_scrollback_and_snaps_back() {
let (tx, _rx) = std::sync::mpsc::channel();
let cwd = std::env::current_dir().unwrap();
let Ok(mut session) = AgentSession::spawn(&cat_spec(), &cwd, tx) else {
return;
};
session.resize(10, 2);
session.push_output(b"L0\r\nL1\r\nL2\r\nL3\r\nL4\r\nL5\r\n");
let bottom = grid_text(&session.grid_snapshot().unwrap());
assert!(
!bottom.contains("L1"),
"L1 should be off-screen: {bottom:?}"
);
session.scroll(4);
let scrolled = grid_text(&session.grid_snapshot().unwrap());
assert!(
scrolled.contains("L1"),
"scroll up reveals L1: {scrolled:?}"
);
session.scroll_to_bottom();
let back = grid_text(&session.grid_snapshot().unwrap());
assert_eq!(back, bottom, "scroll_to_bottom returns to the live view");
session.kill();
}
#[test]
fn decckm_mode_tracks_app_cursor() {
let (tx, _rx) = std::sync::mpsc::channel();
let cwd = std::env::current_dir().unwrap();
let Ok(mut session) = AgentSession::spawn(&cat_spec(), &cwd, tx) else {
return;
};
assert!(!session.term_mode().contains(TermMode::APP_CURSOR));
session.push_output(b"\x1b[?1h");
assert!(session.term_mode().contains(TermMode::APP_CURSOR));
session.push_output(b"\x1b[?1l");
assert!(!session.term_mode().contains(TermMode::APP_CURSOR));
session.kill();
}
}