From 62e50fe83a96b75265fb9522f0eb85a8e2cbdc47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 11:51:51 -0400 Subject: [PATCH] T04: pty through alacritty's event loop, OSC 7 tap on the reader --- AGENTS.md | 1 + Cargo.lock | 4 + Cargo.toml | 4 + src/lib.rs | 1 + src/term/mod.rs | 4 + src/term/pty.rs | 229 ++++++++++++++++++++++++++++++++++++++ src/term/tap.rs | 285 ++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 528 insertions(+) create mode 100644 src/term/mod.rs create mode 100644 src/term/pty.rs create mode 100644 src/term/tap.rs diff --git a/AGENTS.md b/AGENTS.md index 108bef9..d74df63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - `src/main.rs`: start, window. `src/lib.rs`: the modules, public so tests reach them. - `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`. - `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance. +- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser. - `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them). ## Rules diff --git a/Cargo.lock b/Cargo.lock index 010b8ed..e705061 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1748,13 +1748,17 @@ dependencies = [ name = "elyterminal" version = "0.1.0" dependencies = [ + "alacritty_terminal", "anyhow", "dirs 7.0.0", "ely-gpui-component", "env_logger", + "futures", + "gethostname", "gpui-pre", "gpui-pre-platform", "log", + "polling", "serde", "serde_json", "serde_path_to_error", diff --git a/Cargo.toml b/Cargo.toml index e10ce15..2064cda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,13 +9,17 @@ repository = "https://git.zacharyzhang.com/ZacharyZhang-NY/ELYTerminal" publish = false [dependencies] +alacritty_terminal = { version = "0.26", default-features = false } anyhow = "1" dirs = "7" ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "12512217bd9bc652ed6bbf10461b00cc26c01c21" } env_logger = "0.11" +futures = "0.3" +gethostname = "1.1.0" gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false } gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] } log = "0.4" +polling = "3" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_path_to_error = "0.1.20" diff --git a/src/lib.rs b/src/lib.rs index 1100627..98d32f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,3 @@ pub mod settings; +pub mod term; pub mod themes; diff --git a/src/term/mod.rs b/src/term/mod.rs new file mode 100644 index 0000000..c7f9580 --- /dev/null +++ b/src/term/mod.rs @@ -0,0 +1,4 @@ +mod pty; +mod tap; + +pub use pty::{Launch, Listener, Pty, PtyEvent, spawn, window_size}; diff --git a/src/term/pty.rs b/src/term/pty.rs new file mode 100644 index 0000000..b7f6c8f --- /dev/null +++ b/src/term/pty.rs @@ -0,0 +1,229 @@ +use std::{borrow::Cow, collections::HashMap, path::PathBuf, sync::Arc}; + +use alacritty_terminal::{ + event::{Event, EventListener, Notify, OnResize, WindowSize}, + event_loop::{EventLoop, Msg, Notifier}, + sync::FairMutex, + term::{Config, Term, test::TermSize}, + tty::{self, Options, Shell}, +}; +use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded}; + +use super::tap::Tapped; + +/// What a session hears from its pty: the grid's events and the folder the shell reports. +#[derive(Debug)] +pub enum PtyEvent { + Term(Event), + Folder(PathBuf), +} + +/// Hands the grid's events to the session. +#[derive(Clone)] +pub struct Listener(UnboundedSender); + +impl EventListener for Listener { + fn send_event(&self, event: Event) { + if self.0.unbounded_send(PtyEvent::Term(event)).is_err() { + log::debug!("pty: an event after its session closed"); + } + } +} + +/// A program, the folder it starts in, and variables over the app's own. +#[derive(Clone, Debug)] +pub struct Launch { + /// None runs the login shell. + pub program: Option<(String, Vec)>, + pub cwd: PathBuf, + pub env: Vec<(String, String)>, +} + +/// A running program on a pseudo-terminal and the grid it draws into. +pub struct Pty { + pub term: Arc>>, + pub events: UnboundedReceiver, + notifier: Notifier, +} + +/// Starts `launch` on a pty of `size` cells, each `cell` pixels, keeping `scrollback` lines. +pub fn spawn( + launch: &Launch, + size: TermSize, + cell: (u16, u16), + scrollback: usize, +) -> anyhow::Result { + anyhow::ensure!( + launch.cwd.is_dir(), + "pty: cannot start in {}: no such folder", + launch.cwd.display() + ); + let (sender, events) = unbounded(); + let listener = Listener(sender.clone()); + let config = Config { + scrolling_history: scrollback, + kitty_keyboard: true, + ..Config::default() + }; + let term = Arc::new(FairMutex::new(Term::new(config, &size, listener.clone()))); + let mut env = HashMap::from([ + ("TERM".to_string(), "xterm-256color".to_string()), + ("COLORTERM".to_string(), "truecolor".to_string()), + ]); + if std::env::var_os("LANG").is_none() { + env.insert("LANG".into(), "en_US.UTF-8".into()); + } + env.extend(launch.env.iter().cloned()); + let options = Options { + shell: launch + .program + .clone() + .map(|(program, args)| Shell::new(program, args)), + working_directory: Some(launch.cwd.clone()), + drain_on_exit: true, + env, + #[cfg(windows)] + escape_args: true, + }; + let pty = tty::new(&options, window_size(&size, cell), 0)?; + log::info!( + "pty: started {:?} in {}, child {}", + launch.program, + launch.cwd.display(), + child_id(&pty) + ); + let event_loop = EventLoop::new( + term.clone(), + listener, + Tapped::new(pty, sender), + true, + false, + )?; + let notifier = Notifier(event_loop.channel()); + event_loop.spawn(); + Ok(Pty { + term, + events, + notifier, + }) +} + +impl Pty { + /// Sends bytes to the program, as typing does. + pub fn write(&self, bytes: impl Into>) { + self.notifier.notify(bytes); + } + + /// Fits the grid and tells the program its new size. + pub fn resize(&mut self, size: TermSize, cell: (u16, u16)) { + self.notifier.on_resize(window_size(&size, cell)); + self.term.lock().resize(size); + } +} + +impl Drop for Pty { + fn drop(&mut self) { + if self.notifier.0.send(Msg::Shutdown).is_err() { + log::debug!("pty: the event loop had already stopped"); + } + } +} + +#[cfg(unix)] +fn child_id(pty: &tty::Pty) -> String { + pty.child().id().to_string() +} + +#[cfg(windows)] +fn child_id(pty: &tty::Pty) -> String { + pty.child_watcher() + .pid() + .map_or_else(|| "without an id".into(), |pid| pid.to_string()) +} + +/// A pty's size; counts past what the kernel holds are cut and logged. +pub fn window_size(size: &TermSize, cell: (u16, u16)) -> WindowSize { + let cells = |count: usize, side: u16| { + let most = (i16::MAX as u16).min(u16::MAX / side.max(1)); + let kept = u16::try_from(count).unwrap_or(u16::MAX).min(most); + if usize::from(kept) < count { + log::error!("pty: {count} cells of {side} px pass what a pty holds; {kept} kept"); + } + kept + }; + WindowSize { + num_lines: cells(size.screen_lines, cell.1), + num_cols: cells(size.columns, cell.0), + cell_width: cell.0, + cell_height: cell.1, + } +} + +#[cfg(all(test, unix))] +mod tests { + use alacritty_terminal::index::{Column, Line}; + use futures::StreamExt; + + use super::*; + + fn run(script: &str) -> (Vec, String, bool) { + let launch = Launch { + program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])), + cwd: std::env::temp_dir(), + env: vec![("ELY".into(), "ok".into())], + }; + let mut pty = spawn(&launch, TermSize::new(40, 4), (8, 16), 100).expect("sh starts"); + let (folders, success) = futures::executor::block_on(async { + let (mut folders, mut success) = (Vec::new(), false); + while let Some(event) = pty.events.next().await { + match event { + PtyEvent::Folder(folder) => folders.push(folder), + PtyEvent::Term(Event::ChildExit(status)) => success = status.success(), + PtyEvent::Term(Event::Exit) => break, + PtyEvent::Term(_) => {} + } + } + (folders, success) + }); + let term = pty.term.lock(); + let row = &term.grid()[Line(0)]; + let text: String = (0..20).map(|column| row[Column(column)].c).collect(); + (folders, text.trim_end().to_string(), success) + } + + #[test] + fn a_program_prints_into_the_grid_with_its_variables() { + let (folders, text, success) = run("printf 'ely %s' \"$ELY\""); + assert!(folders.is_empty()); + assert_eq!(text, "ely ok"); + assert!(success); + } + + #[test] + fn osc_7_reaches_the_session_as_a_folder() { + let (folders, text, _) = run( + "printf '\\033]7;file:///tmp/a%%20b\\007x\\033]7;file://elsewhere.invalid/c\\033\\\\'", + ); + assert_eq!(folders, [PathBuf::from("/tmp/a b")]); + assert_eq!(text, "x"); + } + + #[test] + fn a_missing_folder_stops_the_start() { + let launch = Launch { + program: None, + cwd: "/nonexistent/ely".into(), + env: Vec::new(), + }; + let error = spawn(&launch, TermSize::new(20, 4), (8, 16), 100) + .err() + .expect("a missing folder is an error"); + assert!(error.to_string().contains("/nonexistent/ely")); + } + + #[test] + fn more_cells_than_a_pty_holds_cap_at_its_most() { + let size = window_size(&TermSize::new(70_000, 24), (8, 16)); + assert_eq!((size.num_cols, size.num_lines), (u16::MAX / 8, 24)); + } +} diff --git a/src/term/tap.rs b/src/term/tap.rs new file mode 100644 index 0000000..a365fd7 --- /dev/null +++ b/src/term/tap.rs @@ -0,0 +1,285 @@ +use std::{io, path::PathBuf, sync::Arc}; + +use alacritty_terminal::{ + event::{OnResize, WindowSize}, + tty::{ChildEvent, EventedPty, EventedReadWrite}, +}; +use futures::channel::mpsc::UnboundedSender; +use polling::{Event, PollMode, Poller}; + +use super::pty::PtyEvent; + +/// The longest OSC body kept; longer ones are dropped whole. +const MOST: usize = 4096; + +/// A pty whose reader hands each byte to an OSC 7 scanner before the parser sees it. +pub(crate) struct Tapped

{ + pub inner: P, + scanner: Osc7, + events: UnboundedSender, +} + +impl

Tapped

{ + pub fn new(inner: P, events: UnboundedSender) -> Self { + Self { + inner, + scanner: Osc7::default(), + events, + } + } +} + +impl io::Read for Tapped

{ + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let count = self.inner.reader().read(buf)?; + let events = &self.events; + self.scanner.feed(&buf[..count], &mut |body| { + if let Some(folder) = folder(body) + && events.unbounded_send(PtyEvent::Folder(folder)).is_err() + { + log::debug!("pty: a folder report after its session closed"); + } + }); + Ok(count) + } +} + +impl EventedReadWrite for Tapped

{ + type Reader = Self; + type Writer = P::Writer; + + unsafe fn register( + &mut self, + poll: &Arc, + event: Event, + mode: PollMode, + ) -> io::Result<()> { + unsafe { self.inner.register(poll, event, mode) } + } + + fn reregister(&mut self, poll: &Arc, event: Event, mode: PollMode) -> io::Result<()> { + self.inner.reregister(poll, event, mode) + } + + fn deregister(&mut self, poll: &Arc) -> io::Result<()> { + self.inner.deregister(poll) + } + + fn reader(&mut self) -> &mut Self { + self + } + + fn writer(&mut self) -> &mut P::Writer { + self.inner.writer() + } +} + +impl EventedPty for Tapped

{ + fn next_child_event(&mut self) -> Option { + self.inner.next_child_event() + } +} + +impl OnResize for Tapped

{ + fn on_resize(&mut self, size: WindowSize) { + self.inner.on_resize(size); + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum State { + #[default] + Ground, + Escape, + Osc, + OscEscape, +} + +/// Finds `ESC ] 7 ; body` ended by BEL or `ESC \`, across reads. +#[derive(Default)] +pub(crate) struct Osc7 { + state: State, + body: Vec, + overflow: bool, +} + +impl Osc7 { + pub fn feed(&mut self, bytes: &[u8], found: &mut impl FnMut(&[u8])) { + let mut rest = bytes; + while !rest.is_empty() { + if self.state == State::Ground { + match rest.iter().position(|&byte| byte == 0x1b) { + Some(at) => { + self.state = State::Escape; + rest = &rest[at + 1..]; + } + None => return, + } + continue; + } + let byte = rest[0]; + rest = &rest[1..]; + self.state = match (self.state, byte) { + (State::Escape, b']') => { + self.body.clear(); + self.overflow = false; + State::Osc + } + (State::Escape, 0x1b) => State::Escape, + (State::Escape, _) => State::Ground, + (State::Osc, 0x07) | (State::OscEscape, b'\\') => { + self.finish(found); + State::Ground + } + (State::Osc | State::OscEscape, 0x18 | 0x1a) => State::Ground, + (State::Osc, 0x1b) => State::OscEscape, + (State::OscEscape, b']') => { + self.body.clear(); + self.overflow = false; + State::Osc + } + (State::OscEscape, 0x1b) => State::Escape, + (State::OscEscape, _) => State::Ground, + (State::Osc, byte) => { + if self.body.len() < MOST { + self.body.push(byte); + } else { + self.overflow = true; + } + State::Osc + } + (State::Ground, _) => unreachable!("ground scans by position"), + }; + } + } + + fn finish(&mut self, found: &mut impl FnMut(&[u8])) { + if self.overflow { + log::warn!("pty: an OSC over {MOST} bytes dropped"); + } else if let Some(body) = self.body.strip_prefix(b"7;") { + found(body); + } + self.body.clear(); + } +} + +/// The local folder an OSC 7 `file://host/path` names; another host's folder is none. +pub(crate) fn folder(body: &[u8]) -> Option { + let Some(rest) = body.strip_prefix(b"file://") else { + log::warn!( + "pty: OSC 7 without file:// ({})", + String::from_utf8_lossy(body) + ); + return None; + }; + let slash = rest.iter().position(|&byte| byte == b'/')?; + let host = String::from_utf8_lossy(&rest[..slash]); + let local = gethostname::gethostname(); + if !(host.is_empty() + || host.eq_ignore_ascii_case("localhost") + || local + .to_str() + .is_some_and(|name| host.eq_ignore_ascii_case(name))) + { + log::debug!("pty: OSC 7 from host {host}, not this one"); + return None; + } + let path = match decode(&rest[slash..]) { + Some(path) => path, + None => { + log::warn!("pty: OSC 7 path is not UTF-8 after decoding"); + return None; + } + }; + let drive = path.as_bytes().get(1..3).is_some_and(|two| two[1] == b':') + && path.as_bytes()[1].is_ascii_alphabetic(); + Some(PathBuf::from(if cfg!(windows) && drive { + &path[1..] + } else { + &path[..] + })) +} + +fn decode(bytes: &[u8]) -> Option { + let mut out = Vec::with_capacity(bytes.len()); + let mut at = 0; + while at < bytes.len() { + let hex = bytes + .get(at + 1..at + 3) + .and_then(|two| std::str::from_utf8(two).ok()) + .and_then(|two| u8::from_str_radix(two, 16).ok()); + match (bytes[at], hex) { + (b'%', Some(value)) => { + out.push(value); + at += 3; + } + (byte, _) => { + out.push(byte); + at += 1; + } + } + } + String::from_utf8(out).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scan(chunks: &[&[u8]]) -> Vec { + let mut scanner = Osc7::default(); + let mut found = Vec::new(); + for chunk in chunks { + scanner.feed(chunk, &mut |body| { + found.push(String::from_utf8(body.to_vec()).expect("utf-8")) + }); + } + found + } + + #[test] + fn finds_osc_7_ended_by_bel_or_st_across_reads() { + assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["file:///a"]); + assert_eq!(scan(&[b"\x1b]7;file:", b"///b\x1b", b"\\"]), ["file:///b"]); + assert_eq!(scan(&[b"\x1b", b"]7;x\x07\x1b]7;y\x1b\\"]), ["x", "y"]); + } + + #[test] + fn other_sequences_and_cancels_pass() { + assert!(scan(&[b"\x1b]0;title\x07\x1b[31m"]).is_empty()); + assert!(scan(&[b"\x1b]7;file:///a\x18\x07"]).is_empty()); + assert_eq!(scan(&[b"\x1b]7;lost\x1b]7;kept\x07"]), ["kept"]); + } + + #[test] + fn an_overlong_body_is_dropped() { + let mut long = b"\x1b]7;".to_vec(); + long.extend(std::iter::repeat_n(b'a', MOST + 1)); + long.push(0x07); + assert!(scan(&[&long]).is_empty()); + assert_eq!(scan(&[&long, b"\x1b]7;b\x07"]), ["b"]); + } + + #[test] + fn a_local_uri_gives_its_decoded_folder() { + assert_eq!( + folder(b"file:///tmp/ely%20dir"), + Some(PathBuf::from("/tmp/ely dir")) + ); + assert_eq!(folder(b"file://localhost/x"), Some(PathBuf::from("/x"))); + let host = gethostname::gethostname().into_string().expect("host"); + let named = format!("file://{host}/y"); + assert_eq!(folder(named.as_bytes()), Some(PathBuf::from("/y"))); + assert_eq!( + folder("file:///%E4%B8%AD".as_bytes()), + Some(PathBuf::from("/δΈ­")) + ); + } + + #[test] + fn other_hosts_and_bad_uris_give_none() { + assert_eq!(folder(b"file://elsewhere.invalid/tmp"), None); + assert_eq!(folder(b"http://x/y"), None); + assert_eq!(folder(b"file:///%FF"), None); + } +}