Files
tty7/src/terminal/remote.rs
T
l0ng-ai a61bd486d3 merge: main — kitty graphics fans out to observers too
Two conflicts, both where main's graphics work and this branch's observer work
touched the same lines.

daemon/protocol.rs: both sides appended frame kinds. INPUT_ACK (51) and
IMAGE/DELETE_IMAGE (60/61) do not collide; both kept.

daemon/pane.rs: main taught the reader to forward a chunk as an ordered
GraphicsFrame sequence instead of one Output, so an image lands at the cursor
cell the sender drew it at. This branch had lifted the same send into
fan_out_output, which also feeds read-only observers and holds each to its
budget. fan_out_output now takes the frame sequence: the no-graphics fast path
still sends one Output, and Image frames reach observers as well, gated on their
own length. A Delete selector rides `notify`, which is ungated but still drops
an observer that has stopped draining — matching the drain accounting in
server.rs.

An observer is a read-only mirror of the pane, so it sees images for the same
reason it sees text.
2026-07-31 13:59:36 +08:00

3001 lines
110 KiB
Rust

#![allow(dead_code)]
use std::borrow::Cow;
use std::io::Read as _;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use alacritty_terminal::event::{Event as AlacEvent, EventListener};
use alacritty_terminal::sync::FairMutex;
use alacritty_terminal::term::{Config, Term, TermMode};
use alacritty_terminal::vte::ansi::{self, CursorShape, CursorStyle};
use crate::terminal::marks::{MarkEvent, MarkScanner};
use std::collections::VecDeque;
use crate::core::cli_agent::{AgentSessionState, CLIAgent};
use crate::core::config::CursorStyle as ConfigCursorStyle;
use crate::core::osc::OscTokenizer;
use crate::daemon::protocol::{
AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId,
LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, LoopbackForwardRequest,
ManagedForward, NativeSshSpec, PaneProcs, RemoteContext, SftpEntry, SftpJobProgress, SftpOp,
SftpOpResult, SftpTransferSpec, ShellSpec, SshForwardRule, SshPhase, WinSize, WorkspaceOp,
WorkspaceRequest,
};
use crate::daemon::transport::{self, Stream};
use super::size::TermSize;
#[derive(Clone)]
pub struct EventProxy {
tx: smol::channel::Sender<AlacEvent>,
replaying: Arc<AtomicBool>,
}
impl EventListener for EventProxy {
fn send_event(&self, event: AlacEvent) {
if self.replaying.load(Ordering::Relaxed)
&& matches!(
event,
AlacEvent::PtyWrite(_)
| AlacEvent::ColorRequest(..)
| AlacEvent::ClipboardStore(..)
| AlacEvent::ClipboardLoad(..)
| AlacEvent::Bell
)
{
return;
}
let _ = self.tx.try_send(event);
}
}
#[derive(Default, Clone, Copy)]
struct ShellState {
active: bool,
at_prompt: bool,
last_exit: Option<i32>,
seq: u64,
cycle: u64,
}
struct ReaderSignals {
cwd: Arc<Mutex<Option<PathBuf>>>,
shell: Arc<Mutex<ShellState>>,
remote: Arc<Mutex<Option<RemoteContext>>>,
agent: Arc<Mutex<Option<CLIAgent>>>,
agent_session: Arc<Mutex<Option<AgentSessionState>>>,
exited: Arc<AtomicBool>,
child_exited: Arc<AtomicBool>,
zle_reading: Arc<AtomicBool>,
shell_vi_mode: Arc<AtomicBool>,
auth: Arc<Mutex<VecDeque<(u64, AuthPromptKind)>>>,
phase: Arc<Mutex<Option<SshPhase>>>,
marks: crate::terminal::marks::Marks,
/// Kitty-graphics images the daemon lifted out of the stream (issue #213),
/// anchored to the grid for the paint path to blit. Shared with the reader,
/// which places/deletes them as `DaemonMsg::Image`/`DeleteImage` frames land.
images: crate::terminal::images::ImageStore,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PaneWorkspace {
pub workspace: crate::core::session::WorkspaceId,
pub target: crate::core::session::RemoteTarget,
pub spec: Option<Box<NativeSshSpec>>,
}
impl PaneWorkspace {
pub fn shares_localhost(&self) -> bool {
matches!(self.target, crate::core::session::RemoteTarget::Wsl { .. })
}
pub fn route_header(&self) -> anyhow::Result<crate::daemon::router::RouteHeader> {
use crate::core::session::RemoteTarget;
use crate::daemon::router::RouteHeader;
let header = match (&self.target, &self.spec) {
(RemoteTarget::Wsl { distro }, _) => RouteHeader::wsl(distro.clone()),
(RemoteTarget::LocalStdio { program, args }, _) => {
let mut argv: Vec<&str> = args.iter().map(String::as_str).collect();
if !argv.contains(&"--pane") {
argv.push("--pane");
}
RouteHeader::local_stdio(program.clone(), &argv)
}
(_, Some(spec)) => RouteHeader::ssh((**spec).clone()),
(target, None) => {
return Err(anyhow::anyhow!(
"this workspace has no SSH connection details ({target:?}), so its panes \
cannot be routed"
));
}
};
Ok(header.for_pane())
}
}
#[derive(Clone, Debug, Default)]
pub enum PaneRoute {
#[default]
Local,
Remote(Box<crate::daemon::router::RouteHeader>),
Unroutable(String),
}
impl PaneRoute {
pub fn for_workspace(workspace: Option<&PaneWorkspace>) -> PaneRoute {
match workspace {
None => PaneRoute::Local,
Some(ws) => match ws.route_header() {
Ok(header) => PaneRoute::Remote(Box::new(header)),
Err(e) => PaneRoute::Unroutable(e.to_string()),
},
}
}
pub fn header(&self) -> Option<&crate::daemon::router::RouteHeader> {
match self {
PaneRoute::Remote(header) => Some(header),
PaneRoute::Local | PaneRoute::Unroutable(_) => None,
}
}
pub fn is_local(&self) -> bool {
matches!(self, PaneRoute::Local)
}
}
pub struct RemoteTerminal {
pub term: Arc<FairMutex<Term<EventProxy>>>,
pub events: smol::channel::Receiver<AlacEvent>,
pub palette: [alacritty_terminal::vte::ansi::Rgb; 256],
pub exited: bool,
size: TermSize,
synced_size: bool,
/// The `(cell_w, cell_h)` last sent to the daemon, in device pixels. Tracked
/// alongside `size` so a display-scale change still reaches the child even
/// when the grid dimensions are unchanged.
synced_cell: (u16, u16),
writer: Mutex<Stream>,
cwd: Arc<Mutex<Option<PathBuf>>>,
shell_state: Arc<Mutex<ShellState>>,
remote_context: Arc<Mutex<Option<RemoteContext>>>,
exited_flag: Arc<AtomicBool>,
child_exited: Arc<AtomicBool>,
zle_reading: Arc<AtomicBool>,
shell_vi_mode: Arc<AtomicBool>,
auth_prompts: Arc<Mutex<VecDeque<(u64, AuthPromptKind)>>>,
ssh_phase: Arc<Mutex<Option<SshPhase>>>,
ssh_endpoint: Option<(String, u16)>,
auto_supplied_password: bool,
agent: Arc<Mutex<Option<CLIAgent>>>,
agent_session: Arc<Mutex<Option<AgentSessionState>>>,
marks: crate::terminal::marks::Marks,
/// Kitty-graphics images placed on this pane's grid (issue #213).
/// Written by the reader thread from out-of-band `Image`/`DeleteImage`
/// frames, read by the paint path — same shared-handle discipline as
/// `marks`, since only the client holds the grid the anchors are relative to.
images: crate::terminal::images::ImageStore,
route: PaneRoute,
proxy: EventProxy,
reader_thread: Option<JoinHandle<()>>,
}
impl RemoteTerminal {
pub fn spawn(
size: TermSize,
cell_w: u16,
cell_h: u16,
cwd: Option<PathBuf>,
shell: Option<ShellSpec>,
) -> anyhow::Result<(Self, u64)> {
Self::spawn_on(&PaneRoute::Local, size, cell_w, cell_h, cwd, shell, None)
}
pub fn spawn_on(
route: &PaneRoute,
size: TermSize,
cell_w: u16,
cell_h: u16,
cwd: Option<PathBuf>,
shell: Option<ShellSpec>,
owner: Option<String>,
) -> anyhow::Result<(Self, u64)> {
let retry_cwd = cwd.clone();
let retry_shell = shell.clone();
let retry_owner = owner.clone();
match Self::spawn_once(route, size, cell_w, cell_h, cwd, shell, owner) {
Ok(term) => Ok(term),
Err(first_err) if daemon_not_listening(&first_err) => {
if let Err(start_err) = crate::daemon::spawn::ensure_running() {
return Err(anyhow::anyhow!(
"daemon not running ({first_err}); starting one failed: {start_err}"
));
}
Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell, retry_owner)
.map_err(|second_err| {
anyhow::anyhow!(
"daemon not running ({first_err}); started one but Spawn still failed: {second_err}"
)
})
}
Err(first_err)
if route.is_local() && daemon_disconnected_before_spawn_reply(&first_err) =>
{
if let Err(restart_err) = crate::daemon::spawn::restart() {
return Err(anyhow::anyhow!(
"daemon disconnected before Spawn reply ({first_err}); restart failed: {restart_err}"
));
}
Self::spawn_once(route, size, cell_w, cell_h, retry_cwd, retry_shell, retry_owner).map_err(|second_err| {
anyhow::anyhow!(
"daemon disconnected before Spawn reply ({first_err}); restarted daemon but Spawn still failed: {second_err}"
)
})
}
Err(err) => Err(err),
}
}
fn spawn_once(
route: &PaneRoute,
size: TermSize,
cell_w: u16,
cell_h: u16,
cwd: Option<PathBuf>,
shell: Option<ShellSpec>,
owner: Option<String>,
) -> anyhow::Result<(Self, u64)> {
let mut stream = connect_routed(route)?;
let win = win_size(size, cell_w, cell_h);
let owner = owner.filter(|_| {
route.is_local()
&& crate::daemon::spawn::local_daemon_supports(
crate::daemon::protocol::FEATURE_PANE_OWNER,
)
});
ClientMsg::Spawn {
cwd,
size: win,
shell,
owner,
workspace: None,
}
.encode(&mut stream)?;
let pane_id = match DaemonMsg::read(&mut stream)? {
DaemonMsg::Spawned { pane_id } => pane_id,
DaemonMsg::Error(msg) => {
return Err(anyhow::anyhow!("daemon refused Spawn: {msg}"));
}
other => {
return Err(anyhow::anyhow!(
"unexpected daemon reply to Spawn: {other:?}"
));
}
};
let mut term = Self::from_stream(stream, size)?;
term.route = route.clone();
Ok((term, pane_id))
}
pub fn attach(size: TermSize, cell_w: u16, cell_h: u16, pane_id: u64) -> anyhow::Result<Self> {
Self::attach_on(&PaneRoute::Local, size, cell_w, cell_h, pane_id)
}
pub fn attach_on(
route: &PaneRoute,
size: TermSize,
cell_w: u16,
cell_h: u16,
pane_id: u64,
) -> anyhow::Result<Self> {
let mut stream = connect_routed(route)?;
let win = win_size(size, cell_w, cell_h);
ClientMsg::Attach { pane_id, size: win }.encode(&mut stream)?;
let buffered = attach_reply_prefix(&mut stream, pane_id, attach_reply_wait(route))?;
let mut term = Self::from_stream_with(stream, size, buffered)?;
term.route = route.clone();
Ok(term)
}
pub fn open_relink(
route: &PaneRoute,
pane_id: u64,
size: TermSize,
cell_w: u16,
cell_h: u16,
) -> anyhow::Result<Stream> {
let mut stream = connect_routed(route)?;
ClientMsg::Attach {
pane_id,
size: win_size(size, cell_w, cell_h),
}
.encode(&mut stream)?;
Ok(stream)
}
pub fn adopt_relink(
&mut self,
stream: Stream,
route: &PaneRoute,
size: TermSize,
cell_w: u16,
cell_h: u16,
) -> anyhow::Result<()> {
if let Ok(writer) = self.writer.lock() {
let _ = writer.shutdown(std::net::Shutdown::Both);
}
if let Some(handle) = self.reader_thread.take() {
let _ = handle.join();
}
while self.events.try_recv().is_ok() {}
let read_half = stream.try_clone()?;
self.exited_flag.store(false, Ordering::SeqCst);
self.exited = false;
{
use alacritty_terminal::vte::ansi::Handler as _;
let mut term = self.term.lock();
term.reset_state();
}
// The grid was just reset, so every image anchor now points nowhere.
// Drop them; the daemon does not replay out-of-band image frames, so a
// browser redraws on its next transmit (see issue #213's reattach note).
self.images.clear();
let reader = Self::spawn_reader(
self.term.clone(),
self.proxy.clone(),
read_half,
Vec::new(),
ReaderSignals {
cwd: self.cwd.clone(),
shell: self.shell_state.clone(),
remote: self.remote_context.clone(),
agent: self.agent.clone(),
agent_session: self.agent_session.clone(),
exited: self.exited_flag.clone(),
child_exited: self.child_exited.clone(),
zle_reading: self.zle_reading.clone(),
shell_vi_mode: self.shell_vi_mode.clone(),
auth: self.auth_prompts.clone(),
phase: self.ssh_phase.clone(),
marks: self.marks.clone(),
images: self.images.clone(),
},
);
if let Ok(mut writer) = self.writer.lock() {
*writer = stream;
}
self.reader_thread = Some(reader);
self.route = route.clone();
self.synced_size = false;
self.resize(size, cell_w, cell_h);
Ok(())
}
pub(super) fn from_stream(stream: Stream, size: TermSize) -> anyhow::Result<Self> {
Self::from_stream_with(stream, size, Vec::new())
}
pub(super) fn from_stream_with(
stream: Stream,
size: TermSize,
buffered: Vec<u8>,
) -> anyhow::Result<Self> {
let read_half = stream.try_clone()?;
let write_half = stream;
let (tx, rx) = smol::channel::unbounded();
let proxy = EventProxy {
tx,
replaying: Arc::new(AtomicBool::new(false)),
};
let user_config = crate::core::config::Config::load();
let config = terminal_config_from_user(&user_config);
let term = Term::new(config, &size, proxy.clone());
let term = Arc::new(FairMutex::new(term));
let cwd: Arc<Mutex<Option<PathBuf>>> = Arc::new(Mutex::new(None));
let shell_state: Arc<Mutex<ShellState>> = Arc::new(Mutex::new(ShellState::default()));
let remote_context: Arc<Mutex<Option<RemoteContext>>> = Arc::new(Mutex::new(None));
let agent: Arc<Mutex<Option<CLIAgent>>> = Arc::new(Mutex::new(None));
let agent_session: Arc<Mutex<Option<AgentSessionState>>> = Arc::new(Mutex::new(None));
let exited_flag = Arc::new(AtomicBool::new(false));
let child_exited = Arc::new(AtomicBool::new(false));
let zle_reading = Arc::new(AtomicBool::new(false));
let shell_vi_mode = Arc::new(AtomicBool::new(false));
let auth_prompts: Arc<Mutex<VecDeque<(u64, AuthPromptKind)>>> =
Arc::new(Mutex::new(VecDeque::new()));
let ssh_phase: Arc<Mutex<Option<SshPhase>>> = Arc::new(Mutex::new(None));
let marks = crate::terminal::marks::Marks::new();
let images = crate::terminal::images::ImageStore::new();
let reader_thread = Self::spawn_reader(
term.clone(),
proxy.clone(),
read_half,
buffered,
ReaderSignals {
cwd: cwd.clone(),
shell: shell_state.clone(),
remote: remote_context.clone(),
agent: agent.clone(),
agent_session: agent_session.clone(),
exited: exited_flag.clone(),
child_exited: child_exited.clone(),
zle_reading: zle_reading.clone(),
shell_vi_mode: shell_vi_mode.clone(),
auth: auth_prompts.clone(),
phase: ssh_phase.clone(),
marks: marks.clone(),
images: images.clone(),
},
);
Ok(Self {
term,
events: rx,
palette: super::palette::build(),
exited: false,
size,
synced_size: false,
synced_cell: (0, 0),
writer: Mutex::new(write_half),
cwd,
shell_state,
remote_context,
exited_flag,
child_exited,
zle_reading,
shell_vi_mode,
auth_prompts,
ssh_phase,
ssh_endpoint: None,
auto_supplied_password: false,
agent,
agent_session,
marks,
images,
route: PaneRoute::Local,
proxy,
reader_thread: Some(reader_thread),
})
}
pub fn detach_link(&mut self) {
if let Ok(mut writer) = self.writer.lock() {
let _ = ClientMsg::Detach.encode(&mut *writer);
let _ = writer.shutdown(std::net::Shutdown::Both);
}
if let Some(handle) = self.reader_thread.take() {
let _ = handle.join();
}
self.poll_exited();
}
pub fn apply_user_config(&self, user_config: &crate::core::config::Config) {
let mut term = self.term.lock();
term.set_options(terminal_config_from_user(user_config));
}
fn spawn_reader(
term: Arc<FairMutex<Term<EventProxy>>>,
proxy: EventProxy,
read_half: Stream,
buffered: Vec<u8>,
signals: ReaderSignals,
) -> JoinHandle<()> {
std::thread::Builder::new()
.name("tty7-remote-reader".to_string())
.spawn(move || {
let ReaderSignals {
cwd,
shell,
remote,
agent,
agent_session,
exited: exited_flag,
child_exited,
zle_reading,
shell_vi_mode,
auth,
phase,
marks,
images,
} = signals;
crate::core::threads::promote_to_user_interactive();
let mut stream = read_half;
let mut processor: ansi::Processor = ansi::Processor::new();
let mut osc = OscNotifyScanner::default();
let mut mode_tok = OscTokenizer::new(&[b"133"]);
let mut zle_tok = OscTokenizer::new(&[b"133"]);
let mut mark_scan = MarkScanner::new();
let mut pending: Vec<u8> = buffered;
let mut pending_size: Option<WinSize> = None;
// Kitty-graphics decode runs on its own thread with newest-frame
// coalescing (issue #213): inflating a full-window browser frame
// is ~42 ms, and doing it inline here would block PTY output and
// scrolling for that long every frame. The worker owns the inflate
// + BGRA swap + placement; the reader only captures the grid
// anchor and hands off the still-compressed frame. Dropped when
// the loop ends, which joins the thread.
let image_decoder = {
let proxy = proxy.clone();
crate::terminal::images::DecodeWorker::spawn(images.clone(), move || {
proxy.send_event(AlacEvent::Wakeup);
})
};
let mut scratch = vec![0u8; 256 * 1024];
let trace = std::env::var("TTY7_TRACE").is_ok_and(|v| !v.is_empty() && v != "0");
let mut tr_last = std::time::Instant::now();
let mut tr_bytes: u64 = 0;
let mut tr_reads: u32 = 0;
let mut tr_read_t = std::time::Duration::ZERO;
let mut tr_lock_t = std::time::Duration::ZERO;
let mut tr_adv_t = std::time::Duration::ZERO;
let mut tr_frames: u32 = 0;
let teardown = || {
term.lock().exit();
exited_flag.store(true, Ordering::SeqCst);
proxy.send_event(AlacEvent::Wakeup);
proxy.send_event(AlacEvent::Exit);
};
let mut out_batch: Vec<u8> = Vec::new();
'main: loop {
macro_rules! flush_batch {
() => {
if !out_batch.is_empty() {
let mut cuts: Vec<(usize, MarkEvent)> = Vec::new();
mark_scan.feed(&out_batch, |off, ev| cuts.push((off, ev)));
{
let t0 = trace.then(std::time::Instant::now);
let mut term = term.lock();
let t1 = trace.then(std::time::Instant::now);
if cuts.is_empty() {
processor.advance(&mut *term, &out_batch);
} else {
let mut at = 0usize;
for (off, ev) in cuts {
processor.advance(&mut *term, &out_batch[at..off]);
at = off;
record_mark(&term, &marks, ev);
}
processor.advance(&mut *term, &out_batch[at..]);
}
if let (Some(t0), Some(t1)) = (t0, t1) {
tr_lock_t += t1 - t0;
tr_adv_t += t1.elapsed();
}
}
let mut notes = Vec::new();
osc.feed(&out_batch, &mut notes);
for (title, body) in notes {
notify_desktop(title.as_deref(), &body);
}
mode_tok.feed(&out_batch, |payload| {
if let Some(mode) = payload.strip_prefix(b"133;V;") {
shell_vi_mode.store(
mode.first() == Some(&b'1'),
Ordering::Relaxed,
);
}
});
zle_tok.feed(&out_batch, |payload| {
if let Some(mark) = payload.strip_prefix(b"133;") {
match mark.first() {
Some(b'B') => {
zle_reading.store(true, Ordering::Relaxed)
}
Some(b'V') => {
shell_vi_mode.store(
mark.strip_prefix(b"V;")
.is_some_and(|v| v.first() == Some(&b'1')),
Ordering::Relaxed,
);
}
_ => zle_reading.store(false, Ordering::Relaxed),
}
}
});
proxy.send_event(AlacEvent::Wakeup);
out_batch.clear();
}
};
}
loop {
let frame = match crate::daemon::protocol::take_frame(&mut pending) {
Ok(Some(frame)) => frame,
Ok(None) => break,
Err(_) => {
teardown();
break 'main;
}
};
let msg = match DaemonMsg::from_frame(frame.0, frame.1) {
Ok(msg) => msg,
Err(_) => {
teardown();
break 'main;
}
};
match msg {
DaemonMsg::Size(ws) => {
flush_batch!();
pending_size = Some(ws);
}
DaemonMsg::Snapshot(bytes) => {
flush_batch!();
proxy.replaying.store(true, Ordering::Relaxed);
{
let mut term = term.lock();
if let Some(ws) = pending_size.take() {
term.resize(TermSize::new(
ws.cols as usize,
ws.rows as usize,
));
}
processor.advance(&mut *term, &bytes);
if processor.sync_timeout().sync_timeout().is_some() {
processor.stop_sync(&mut *term);
}
}
mode_tok.feed(&bytes, |payload| {
if let Some(mode) = payload.strip_prefix(b"133;V;") {
shell_vi_mode.store(
mode.first() == Some(&b'1'),
Ordering::Relaxed,
);
}
});
proxy.replaying.store(false, Ordering::Relaxed);
proxy.send_event(AlacEvent::Wakeup);
}
DaemonMsg::Output(bytes) => {
out_batch.extend_from_slice(&bytes);
tr_frames += 1;
}
// Kitty graphics (issue #213): the daemon lifted an
// image out of the stream and forwarded it out-of-band,
// interleaved *in stream order* with the Output frames
// around it. Flush the pending text first so the grid
// cursor sits where the sender drew the image, then
// anchor the placement to that cell in scroll-stable
// absolute-row coordinates (the same formula
// `record_mark` uses), so it tracks scrolling.
DaemonMsg::Image(frame) => {
flush_batch!();
if let Some(img) =
tty7_core::core::kitty_graphics::Image::decode_frame(&frame)
{
// Capture the anchor *now*, at the cursor cell
// the transmission arrived on; the decode is
// deferred to the worker thread but must land
// at this position, not wherever the cursor has
// scrolled to by the time inflate finishes.
let (anchor_row, anchor_col) = {
use alacritty_terminal::grid::Dimensions as _;
let term = term.lock();
let grid = term.grid();
let row = grid.history_size() as i64
- grid.display_offset() as i64
+ i64::from(grid.cursor.point.line.0);
(row, grid.cursor.point.column.0)
};
// Hand off without blocking the reader: the
// worker inflates, swaps, places, and wakes the
// view. Stale frames coalesce away there.
image_decoder.submit(
crate::terminal::images::PendingFrame {
img,
anchor_row,
anchor_col,
},
);
}
}
// An `a=d` delete, lifted out the same way. Order with
// the surrounding output does not matter for a delete
// (it targets by id/placement, not cursor position),
// but flushing keeps a delete-then-retransmit in the
// same read from racing its own replacement.
DaemonMsg::DeleteImage(sel) => {
flush_batch!();
if let Some(del) =
tty7_core::core::kitty_graphics::ImageDelete::decode(&sel)
{
images.delete(&del);
proxy.send_event(AlacEvent::Wakeup);
}
}
DaemonMsg::Cwd(path) => {
flush_batch!();
if let Ok(mut guard) = cwd.lock() {
*guard = Some(path);
}
}
DaemonMsg::Prompt {
active,
at_prompt,
last_exit,
} => {
flush_batch!();
if let Ok(mut guard) = shell.lock() {
*guard = ShellState {
active,
at_prompt,
last_exit,
seq: guard.seq + 1,
cycle: guard.cycle
+ u64::from(at_prompt && !guard.at_prompt),
};
}
if active && at_prompt {
let mut term = term.lock();
let resets = stale_mode_resets(*term.mode());
if !resets.is_empty() {
processor.advance(&mut *term, &resets);
drop(term);
proxy.send_event(AlacEvent::Wakeup);
}
}
}
DaemonMsg::RemoteContext(ctx) => {
flush_batch!();
if let Ok(mut guard) = cwd.lock() {
*guard = None;
}
if let Ok(mut guard) = remote.lock() {
*guard = ctx;
}
}
DaemonMsg::AuthPrompt { request_id, prompt } => {
flush_batch!();
if let Ok(mut guard) = auth.lock() {
guard.push_back((request_id, prompt));
}
proxy.send_event(AlacEvent::Wakeup);
}
DaemonMsg::SshStatus { phase: p } => {
flush_batch!();
if let Ok(mut guard) = phase.lock() {
*guard = Some(p);
}
proxy.send_event(AlacEvent::Wakeup);
}
DaemonMsg::Agent(a) => {
flush_batch!();
if let Ok(mut guard) = agent.lock() {
*guard = a;
}
}
DaemonMsg::AgentStatus(state) => {
flush_batch!();
if let Ok(mut guard) = agent_session.lock() {
*guard = state;
}
proxy.send_event(AlacEvent::Wakeup);
}
DaemonMsg::Exited { .. } => {
flush_batch!();
child_exited.store(true, Ordering::SeqCst);
teardown();
break 'main;
}
_ => {}
}
}
flush_batch!();
let timeout = match processor.sync_timeout().sync_timeout() {
Some(deadline) => {
let left =
deadline.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
let mut term = term.lock();
processor.stop_sync(&mut *term);
drop(term);
proxy.send_event(AlacEvent::Wakeup);
continue;
}
Some(left)
}
None => None,
};
let _ = stream.set_read_timeout(timeout);
if trace && tr_last.elapsed() >= std::time::Duration::from_secs(1) {
eprintln!(
"[trace client] {:.1} MB/s | {} reads ({} B/read) {} frames | read wait {:?} lock wait {:?} advance {:?}",
tr_bytes as f64 / tr_last.elapsed().as_secs_f64() / 1e6,
tr_reads,
if tr_reads > 0 { tr_bytes / tr_reads as u64 } else { 0 },
tr_frames,
tr_read_t,
tr_lock_t,
tr_adv_t,
);
tr_last = std::time::Instant::now();
tr_bytes = 0;
tr_reads = 0;
tr_frames = 0;
tr_read_t = std::time::Duration::ZERO;
tr_lock_t = std::time::Duration::ZERO;
tr_adv_t = std::time::Duration::ZERO;
}
let tr0 = trace.then(std::time::Instant::now);
match stream.read(&mut scratch) {
Ok(0) => {
teardown();
break;
}
Ok(n) => {
if let Some(tr0) = tr0 {
tr_read_t += tr0.elapsed();
tr_reads += 1;
tr_bytes += n as u64;
}
pending.extend_from_slice(&scratch[..n]);
}
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) => {}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => {
teardown();
break;
}
}
}
})
.expect("spawn remote reader thread")
}
pub fn poll_exited(&mut self) {
if self.exited_flag.load(Ordering::SeqCst) {
self.exited = true;
}
}
pub fn child_exited(&self) -> bool {
self.child_exited.load(Ordering::SeqCst)
}
pub fn write<B: Into<Cow<'static, [u8]>>>(&self, bytes: B) {
let bytes = bytes.into();
if bytes.is_empty() {
return;
}
if let Ok(mut writer) = self.writer.lock() {
let _ = ClientMsg::Input(bytes.into_owned()).encode(&mut *writer);
}
}
pub fn resize(&mut self, size: TermSize, cell_w: u16, cell_h: u16) {
// The cell size has to be part of the early-out, not just cols/rows: it
// is reported in *device* pixels, so moving the window between a 2x and
// a 1x display changes `ws_xpixel`/`ws_ypixel` while the grid stays
// exactly the same. Comparing only `size` there would skip the resize
// and leave a pixel-aware child rendering for the old framebuffer.
let cell = (cell_w, cell_h);
if self.synced_size && size == self.size && cell == self.synced_cell {
use alacritty_terminal::grid::Dimensions as _;
let term = self.term.lock();
if term.columns() == size.cols && term.screen_lines() == size.rows {
return;
}
}
self.synced_size = true;
self.size = size;
self.synced_cell = cell;
self.term.lock().resize(size);
let win = win_size(size, cell_w, cell_h);
if let Ok(mut writer) = self.writer.lock() {
let _ = ClientMsg::Resize(win).encode(&mut *writer);
}
}
pub fn foreground_cwd(&self) -> Option<PathBuf> {
self.cwd.lock().ok().and_then(|g| g.clone())
}
pub fn remote_context(&self) -> Option<RemoteContext> {
self.remote_context.lock().ok().and_then(|g| g.clone())
}
pub fn at_prompt(&self) -> bool {
self.shell_state
.lock()
.map(|s| s.active && s.at_prompt)
.unwrap_or(false)
}
pub fn prompt_seq(&self) -> u64 {
self.shell_state.lock().map(|s| s.seq).unwrap_or(0)
}
pub fn prompt_cycle(&self) -> u64 {
self.shell_state.lock().map(|s| s.cycle).unwrap_or(0)
}
pub fn last_exit_code(&self) -> Option<i32> {
self.shell_state.lock().ok().and_then(|s| s.last_exit)
}
pub fn shell_active(&self) -> bool {
self.shell_state.lock().map(|s| s.active).unwrap_or(false)
}
pub fn foreground_agent(&self) -> Option<CLIAgent> {
self.agent.lock().ok().and_then(|g| *g)
}
pub fn marks(&self) -> crate::terminal::marks::Marks {
self.marks.clone()
}
/// The kitty-graphics image store for this pane. Cheap handle clone — the
/// store is an `Arc<Mutex<..>>` shared with the reader thread, which places
/// and deletes images as out-of-band frames arrive from the daemon.
pub fn images(&self) -> crate::terminal::images::ImageStore {
self.images.clone()
}
pub fn agent_session(&self) -> Option<AgentSessionState> {
self.agent_session.lock().ok().and_then(|g| g.clone())
}
pub fn zle_reading(&self) -> bool {
self.zle_reading.load(Ordering::Relaxed)
}
pub fn shell_vi_mode(&self) -> bool {
self.shell_vi_mode.load(Ordering::Relaxed)
}
pub fn size(&self) -> TermSize {
self.size
}
pub fn list_panes() -> Vec<crate::daemon::protocol::PaneInfo> {
Self::list_panes_on(&PaneRoute::Local)
}
pub fn list_panes_on(route: &PaneRoute) -> Vec<crate::daemon::protocol::PaneInfo> {
Self::try_list_panes_on(route).unwrap_or_default()
}
pub fn try_list_panes_on(
route: &PaneRoute,
) -> anyhow::Result<Vec<crate::daemon::protocol::PaneInfo>> {
let mut stream = connect_routed(route)?;
ClientMsg::List.encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::PaneList(list) => Ok(list),
other => Err(anyhow::anyhow!("unexpected reply to List: {other:?}")),
}
}
pub fn kill_pane(pane_id: u64) {
Self::kill_pane_on(&PaneRoute::Local, pane_id)
}
pub fn kill_pane_on(route: &PaneRoute, pane_id: u64) {
if let Ok(mut stream) = connect_routed(route) {
let _ = ClientMsg::Kill { pane_id }.encode(&mut stream);
let _ = stream.shutdown(std::net::Shutdown::Write);
}
}
pub fn ensure_loopback_forward(
pane_id: u64,
remote_host: &str,
remote_port: u16,
) -> anyhow::Result<LoopbackForward> {
let mut stream = connect()?;
ClientMsg::EnsureLoopbackForward(LoopbackForwardRequest {
pane_id,
remote_host: remote_host.to_string(),
remote_port,
})
.encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::LoopbackForward(forward) => Ok(forward),
DaemonMsg::Error(msg) => Err(anyhow::anyhow!(msg)),
other => Err(anyhow::anyhow!(
"unexpected reply to EnsureLoopbackForward: {other:?}"
)),
}
}
pub fn list_loopback_forwards() -> Vec<LoopbackForwardInfo> {
fn query() -> anyhow::Result<Vec<LoopbackForwardInfo>> {
let mut stream = connect()?;
ClientMsg::ListLoopbackForwards.encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::LoopbackForwardList(list) => Ok(list),
other => Err(anyhow::anyhow!(
"unexpected reply to ListLoopbackForwards: {other:?}"
)),
}
}
query().unwrap_or_default()
}
pub fn close_loopback_forward(id: LoopbackForwardId) -> Vec<LoopbackForwardInfo> {
fn query(id: LoopbackForwardId) -> anyhow::Result<Vec<LoopbackForwardInfo>> {
let mut stream = connect()?;
ClientMsg::CloseLoopbackForward(id).encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::LoopbackForwardList(list) => Ok(list),
other => Err(anyhow::anyhow!(
"unexpected reply to CloseLoopbackForward: {other:?}"
)),
}
}
query(id).unwrap_or_default()
}
pub fn spawn_native_ssh(
size: TermSize,
cell_w: u16,
cell_h: u16,
cwd: Option<PathBuf>,
spec: Box<NativeSshSpec>,
) -> anyhow::Result<(Self, u64)> {
match Self::spawn_native_ssh_once(size, cell_w, cell_h, cwd.clone(), spec.clone()) {
Err(first_err) if daemon_disconnected_before_spawn_reply(&first_err) => {
if let Err(restart_err) = crate::daemon::spawn::restart() {
return Err(anyhow::anyhow!(
"daemon disconnected before SpawnNativeSsh reply ({first_err}); restart failed: {restart_err}"
));
}
Self::spawn_native_ssh_once(size, cell_w, cell_h, cwd, spec).map_err(|second_err| {
anyhow::anyhow!(
"daemon disconnected before SpawnNativeSsh reply ({first_err}); restarted daemon but it still failed: {second_err}"
)
})
}
other => other,
}
}
fn spawn_native_ssh_once(
size: TermSize,
cell_w: u16,
cell_h: u16,
cwd: Option<PathBuf>,
spec: Box<NativeSshSpec>,
) -> anyhow::Result<(Self, u64)> {
let mut stream = connect()?;
let win = win_size(size, cell_w, cell_h);
let endpoint = (spec.host.clone(), spec.port);
let auto_supplied_password = spec.password.is_some();
ClientMsg::SpawnNativeSsh {
cwd,
size: win,
spec,
}
.encode(&mut stream)?;
let pane_id = match DaemonMsg::read(&mut stream)? {
DaemonMsg::Spawned { pane_id } => pane_id,
DaemonMsg::Error(msg) => {
return Err(anyhow::anyhow!("daemon refused SpawnNativeSsh: {msg}"));
}
other => {
return Err(anyhow::anyhow!(
"unexpected daemon reply to SpawnNativeSsh: {other:?}"
));
}
};
let mut term = Self::from_stream(stream, size)?;
term.ssh_endpoint = Some(endpoint);
term.auto_supplied_password = auto_supplied_password;
Ok((term, pane_id))
}
pub fn take_auth_prompt(&self) -> Option<(u64, AuthPromptKind)> {
self.auth_prompts
.lock()
.ok()
.and_then(|mut q| q.pop_front())
}
pub fn take_auth_banner(&self) -> Option<String> {
let mut q = self.auth_prompts.lock().ok()?;
if matches!(q.front(), Some((_, AuthPromptKind::Banner { .. }))) {
if let Some((_, AuthPromptKind::Banner { text })) = q.pop_front() {
return Some(text);
}
}
None
}
pub fn has_pending_auth(&self) -> bool {
self.auth_prompts
.lock()
.map(|q| !q.is_empty())
.unwrap_or(false)
}
pub fn ssh_phase(&self) -> Option<SshPhase> {
self.ssh_phase.lock().ok().and_then(|g| g.clone())
}
pub fn ssh_endpoint(&self) -> Option<(String, u16)> {
self.ssh_endpoint.clone()
}
pub fn auto_supplied_password(&self) -> bool {
self.auto_supplied_password
}
pub fn respond_auth(&self, request_id: u64, response: AuthResponse) {
if let Ok(mut writer) = self.writer.lock() {
let _ = ClientMsg::AuthResponse {
request_id,
response,
}
.encode(&mut *writer);
}
}
pub fn list_known_hosts() -> Vec<KnownHostEntry> {
fn query() -> anyhow::Result<Vec<KnownHostEntry>> {
let mut stream = connect()?;
ClientMsg::ListKnownHosts.encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::KnownHostsList(list) => Ok(list),
other => Err(anyhow::anyhow!(
"unexpected reply to ListKnownHosts: {other:?}"
)),
}
}
query().unwrap_or_default()
}
pub fn delete_known_host(id: KnownHostId) -> Vec<KnownHostEntry> {
fn query(id: KnownHostId) -> anyhow::Result<Vec<KnownHostEntry>> {
let mut stream = connect()?;
ClientMsg::DeleteKnownHost(id).encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::KnownHostsList(list) => Ok(list),
other => Err(anyhow::anyhow!(
"unexpected reply to DeleteKnownHost: {other:?}"
)),
}
}
query(id).unwrap_or_default()
}
pub fn sftp_list(pane_id: u64, path: &str) -> Result<Vec<SftpEntry>, String> {
fn query(pane_id: u64, path: String) -> anyhow::Result<Result<Vec<SftpEntry>, String>> {
let mut stream = connect()?;
ClientMsg::SftpList { pane_id, path }.encode(&mut stream)?;
Ok(match DaemonMsg::read(&mut stream)? {
DaemonMsg::SftpEntries(entries) => Ok(entries),
DaemonMsg::Error(msg) => Err(msg),
other => Err(format!("unexpected reply to SftpList: {other:?}")),
})
}
query(pane_id, path.to_string()).unwrap_or_else(|e| Err(e.to_string()))
}
pub fn sftp_op(pane_id: u64, op: SftpOp) -> SftpOpResult {
fn query(pane_id: u64, op: SftpOp) -> anyhow::Result<SftpOpResult> {
let mut stream = connect()?;
ClientMsg::SftpOp { pane_id, op }.encode(&mut stream)?;
Ok(match DaemonMsg::read(&mut stream)? {
DaemonMsg::SftpOpResult(result) => result,
DaemonMsg::Error(msg) => SftpOpResult::Error(msg),
other => SftpOpResult::Error(format!("unexpected reply to SftpOp: {other:?}")),
})
}
query(pane_id, op).unwrap_or_else(|e| SftpOpResult::Error(e.to_string()))
}
pub fn sftp_transfer_start(spec: SftpTransferSpec) -> Result<u64, String> {
fn query(spec: SftpTransferSpec) -> anyhow::Result<Result<u64, String>> {
let mut stream = connect()?;
ClientMsg::SftpTransferStart(spec).encode(&mut stream)?;
Ok(match DaemonMsg::read(&mut stream)? {
DaemonMsg::SftpTransferStarted { job_id } => Ok(job_id),
DaemonMsg::Error(msg) => Err(msg),
other => Err(format!("unexpected reply to SftpTransferStart: {other:?}")),
})
}
query(spec).unwrap_or_else(|e| Err(e.to_string()))
}
pub fn sftp_transfer_cancel(job_id: u64) -> Vec<SftpJobProgress> {
fn query(job_id: u64) -> anyhow::Result<Vec<SftpJobProgress>> {
let mut stream = connect()?;
ClientMsg::SftpTransferCancel { job_id }.encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::SftpTransferProgress(jobs) => Ok(jobs),
other => Err(anyhow::anyhow!(
"unexpected reply to SftpTransferCancel: {other:?}"
)),
}
}
query(job_id).unwrap_or_default()
}
pub fn sftp_transfer_list(pane_id: u64) -> Vec<SftpJobProgress> {
fn query(pane_id: u64) -> anyhow::Result<Vec<SftpJobProgress>> {
let mut stream = connect()?;
ClientMsg::SftpTransferList { pane_id }.encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::SftpTransferProgress(jobs) => Ok(jobs),
other => Err(anyhow::anyhow!(
"unexpected reply to SftpTransferList: {other:?}"
)),
}
}
query(pane_id).unwrap_or_default()
}
pub fn add_forward(pane_id: u64, rule: SshForwardRule) -> Vec<ManagedForward> {
fn query(pane_id: u64, rule: SshForwardRule) -> anyhow::Result<Vec<ManagedForward>> {
let mut stream = connect()?;
ClientMsg::AddForward { pane_id, rule }.encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::ForwardList(list) => Ok(list),
DaemonMsg::Error(msg) => Err(anyhow::anyhow!(msg)),
other => Err(anyhow::anyhow!("unexpected reply to AddForward: {other:?}")),
}
}
query(pane_id, rule).unwrap_or_default()
}
pub fn remove_forward(pane_id: u64, forward_id: u64) -> Vec<ManagedForward> {
fn query(pane_id: u64, forward_id: u64) -> anyhow::Result<Vec<ManagedForward>> {
let mut stream = connect()?;
ClientMsg::RemoveForward {
pane_id,
forward_id,
}
.encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::ForwardList(list) => Ok(list),
other => Err(anyhow::anyhow!(
"unexpected reply to RemoveForward: {other:?}"
)),
}
}
query(pane_id, forward_id).unwrap_or_default()
}
pub fn list_forwards(pane_id: u64) -> Vec<ManagedForward> {
fn query(pane_id: u64) -> anyhow::Result<Vec<ManagedForward>> {
let mut stream = connect()?;
ClientMsg::ListForwards { pane_id }.encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::ForwardList(list) => Ok(list),
other => Err(anyhow::anyhow!(
"unexpected reply to ListForwards: {other:?}"
)),
}
}
query(pane_id).unwrap_or_default()
}
const WORKSPACE_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
pub fn on_workspace(req: WorkspaceRequest) -> anyhow::Result<DaemonMsg> {
let mut stream = connect()?;
let _ = stream.set_read_timeout(Some(Self::WORKSPACE_OP_TIMEOUT));
ClientMsg::OnWorkspace(Box::new(req)).encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::Error(msg) => Err(anyhow::anyhow!(msg)),
reply => Ok(reply),
}
}
pub fn on_workspace_forwards(req: WorkspaceRequest) -> Vec<ManagedForward> {
match Self::on_workspace(req) {
Ok(DaemonMsg::ForwardList(list)) => list,
Ok(other) => {
log::warn!("unexpected reply to a workspace forward request: {other:?}");
Vec::new()
}
Err(e) => {
log::warn!("workspace forward request failed: {e}");
Vec::new()
}
}
}
pub fn workspace_request(
ws: &PaneWorkspace,
view_pane: u64,
op: WorkspaceOp,
) -> Option<WorkspaceRequest> {
Some(WorkspaceRequest {
workspace: ws.workspace,
spec: ws.spec.clone()?,
view_pane,
op,
})
}
pub fn query_procs(pane_id: u64) -> PaneProcs {
fn query(pane_id: u64) -> anyhow::Result<PaneProcs> {
let mut stream = connect()?;
ClientMsg::QueryProcs { pane_id }.encode(&mut stream)?;
match DaemonMsg::read(&mut stream)? {
DaemonMsg::Procs(procs) => Ok(procs),
other => Err(anyhow::anyhow!("unexpected reply to QueryProcs: {other:?}")),
}
}
query(pane_id).unwrap_or_default()
}
}
fn record_mark(term: &Term<EventProxy>, marks: &crate::terminal::marks::Marks, event: MarkEvent) {
use alacritty_terminal::grid::Dimensions as _;
match event {
MarkEvent::Prompt => {
let grid = term.grid();
let row = grid.history_size() as i64 - grid.display_offset() as i64
+ i64::from(grid.cursor.point.line.0);
marks.begin(row, String::new());
}
MarkEvent::Command(cmd) => marks.set_text(cmd),
MarkEvent::Done(exit) => marks.finish(exit),
}
}
fn daemon_not_listening(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause.downcast_ref::<std::io::Error>().is_some_and(|io| {
matches!(
io.kind(),
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
)
})
})
}
fn attach_reply_wait(route: &PaneRoute) -> std::time::Duration {
match route.is_local() {
true => std::time::Duration::from_secs(2),
false => std::time::Duration::from_secs(15),
}
}
fn attach_reply_prefix(
stream: &mut Stream,
pane_id: u64,
wait: std::time::Duration,
) -> anyhow::Result<Vec<u8>> {
use std::io::Read as _;
let _ = stream.set_read_timeout(Some(wait));
let mut buffered: Vec<u8> = Vec::new();
let mut scratch = [0u8; 4096];
let mut kind = None;
while kind.is_none() {
match stream.read(&mut scratch) {
Ok(0) => {
let _ = stream.set_read_timeout(None);
return Err(anyhow::anyhow!(
"the daemon closed the connection without answering Attach for pane {pane_id}"
));
}
Ok(n) => buffered.extend_from_slice(&scratch[..n]),
Err(e) if would_block(&e) => break,
Err(e) => {
let _ = stream.set_read_timeout(None);
return Err(anyhow::Error::new(e).context(format!(
"reading the daemon's answer to Attach for pane {pane_id}"
)));
}
}
kind = crate::daemon::protocol::peek_frame_kind(&buffered);
}
let _ = stream.set_read_timeout(None);
if !kind.is_some_and(crate::daemon::protocol::is_error_kind) {
return Ok(buffered);
}
let message = read_error_frame(stream, &mut buffered, wait)
.unwrap_or_else(|| format!("no such pane {pane_id}"));
Err(anyhow::anyhow!("daemon refused Attach: {message}"))
}
fn read_error_frame(
stream: &mut Stream,
buffered: &mut Vec<u8>,
wait: std::time::Duration,
) -> Option<String> {
use std::io::Read as _;
let _ = stream.set_read_timeout(Some(wait));
let mut scratch = [0u8; 1024];
let message = loop {
match crate::daemon::protocol::take_frame(buffered) {
Ok(Some(frame)) => match DaemonMsg::from_frame(frame.0, frame.1) {
Ok(DaemonMsg::Error(message)) => break Some(message),
_ => break None,
},
Ok(None) => match stream.read(&mut scratch) {
Ok(0) => break None,
Ok(n) => buffered.extend_from_slice(&scratch[..n]),
Err(_) => break None,
},
Err(_) => break None,
}
};
let _ = stream.set_read_timeout(None);
message
}
fn would_block(err: &std::io::Error) -> bool {
matches!(
err.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
)
}
fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause.downcast_ref::<std::io::Error>().is_some_and(|io| {
matches!(
io.kind(),
std::io::ErrorKind::UnexpectedEof
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::BrokenPipe
)
})
})
}
impl Drop for RemoteTerminal {
fn drop(&mut self) {
if let Ok(mut writer) = self.writer.lock() {
let _ = ClientMsg::Detach.encode(&mut *writer);
let _ = writer.shutdown(std::net::Shutdown::Both);
}
if let Some(handle) = self.reader_thread.take() {
let _ = handle.join();
}
}
}
fn stale_mode_resets(mode: TermMode) -> Vec<u8> {
let mut seq = Vec::new();
if mode.contains(TermMode::ALT_SCREEN) {
seq.extend_from_slice(b"\x1b[?1049l");
}
if !mode.contains(TermMode::SHOW_CURSOR) {
seq.extend_from_slice(b"\x1b[?25h");
}
if mode.intersects(TermMode::MOUSE_MODE) {
seq.extend_from_slice(b"\x1b[?1000l\x1b[?1002l\x1b[?1003l");
}
if mode.contains(TermMode::SGR_MOUSE) {
seq.extend_from_slice(b"\x1b[?1006l");
}
if mode.contains(TermMode::UTF8_MOUSE) {
seq.extend_from_slice(b"\x1b[?1005l");
}
if mode.contains(TermMode::FOCUS_IN_OUT) {
seq.extend_from_slice(b"\x1b[?1004l");
}
if mode.intersects(TermMode::KITTY_KEYBOARD_PROTOCOL) || mode.contains(TermMode::ALT_SCREEN) {
seq.extend_from_slice(b"\x1b[=0;1u");
}
seq
}
pub(crate) fn notify_desktop(title: Option<&str>, body: &str) {
let summary = title.unwrap_or("tty7").to_string();
let body = body.to_string();
std::thread::spawn(move || {
#[cfg(target_os = "macos")]
ensure_notification_app();
let _ = notify_rust::Notification::new()
.summary(&summary)
.body(&body)
.show();
});
}
#[cfg(target_os = "macos")]
fn ensure_notification_app() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
if notify_rust::set_application("com.github.tty7").is_err() {
let _ = notify_rust::set_application("com.apple.Terminal");
}
});
}
struct OscNotifyScanner {
tok: OscTokenizer,
}
impl Default for OscNotifyScanner {
fn default() -> Self {
Self {
tok: OscTokenizer::new(&[b"9", b"777"]),
}
}
}
impl OscNotifyScanner {
fn feed(&mut self, bytes: &[u8], out: &mut Vec<(Option<String>, String)>) {
self.tok.feed(bytes, |payload| {
if let Some(note) = parse_osc_notification(payload) {
out.push(note);
}
});
}
}
fn parse_osc_notification(payload: &[u8]) -> Option<(Option<String>, String)> {
if crate::core::cli_agent::parse_agent_event(payload).is_some() {
return None;
}
let (title, body) = crate::core::osc::parse_notification(payload)?;
if title.as_deref() == Some(crate::core::cli_agent::AGENT_EVENT_SENTINEL) {
return None;
}
Some((title, body))
}
fn connect() -> anyhow::Result<Stream> {
transport::connect().map_err(|e| {
anyhow::Error::new(e).context(format!(
"connect to daemon at {}",
transport::endpoint_display()
))
})
}
fn connect_routed(route: &PaneRoute) -> anyhow::Result<Stream> {
if let PaneRoute::Unroutable(reason) = route {
return Err(anyhow::anyhow!("{reason}"));
}
let Some(header) = route.header() else {
return connect();
};
tty7_core::host::guard_off_ui();
if let crate::daemon::router::RouteTarget::Wsl { distro } = &header.target {
crate::daemon::install::wsl::ensure_wsl_server(distro)
.map_err(|e| anyhow::anyhow!("prepare tty7-server in WSL `{distro}`: {e}"))?;
}
let mut stream = connect()?;
let ack = crate::daemon::router::negotiate(&mut stream, header)
.map_err(|e| anyhow::anyhow!("route this pane to {}: {e}", header.describe()))?;
log::debug!(
"pane routed to {} over {}",
header.describe(),
ack.link.as_deref().unwrap_or("?")
);
Ok(stream)
}
fn terminal_config_from_user(user_config: &crate::core::config::Config) -> Config {
Config {
scrolling_history: user_config.scrollback_limit,
default_cursor_style: alacritty_cursor_style(user_config.cursor_style),
semantic_escape_chars: user_config.word_separators.clone(),
kitty_keyboard: true,
..Config::default()
}
}
fn alacritty_cursor_style(style: ConfigCursorStyle) -> CursorStyle {
let shape = match style {
ConfigCursorStyle::Block => CursorShape::Block,
ConfigCursorStyle::Bar => CursorShape::Beam,
ConfigCursorStyle::Underline => CursorShape::Underline,
};
CursorStyle {
shape,
blinking: false,
}
}
fn win_size(size: TermSize, cell_w: u16, cell_h: u16) -> WinSize {
WinSize {
cols: size.cols as u16,
rows: size.rows as u16,
cell_w,
cell_h,
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::io::Write;
use std::os::unix::net::UnixStream;
fn ssh_workspace() -> PaneWorkspace {
PaneWorkspace {
workspace: crate::core::session::WorkspaceId::new(),
target: crate::core::session::RemoteTarget::Direct {
user: "me".into(),
host: "build-box".into(),
port: 22,
},
spec: Some(Box::new(
serde_json::from_str(
r#"{"host":"build-box","port":22,"user":"me","auth_mode":"auto"}"#,
)
.unwrap(),
)),
}
}
#[test]
fn a_local_pane_prefixes_nothing() {
assert!(PaneRoute::Local.header().is_none());
assert!(PaneRoute::for_workspace(None).header().is_none());
assert!(matches!(PaneRoute::for_workspace(None), PaneRoute::Local));
assert!(matches!(PaneRoute::default(), PaneRoute::Local));
}
#[test]
fn a_remote_pane_routes_to_its_machine_on_the_pane_channel() {
let route = PaneRoute::for_workspace(Some(&ssh_workspace()));
let header = route.header().expect("a remote pane is routed");
assert_eq!(
header.channel,
crate::daemon::router::RouteChannel::Pane,
"a pane must not be sent to the control socket"
);
assert_eq!(header.describe(), "ssh me@build-box:22");
}
#[test]
fn a_wsl_workspace_routes_by_distro() {
let ws = PaneWorkspace {
workspace: crate::core::session::WorkspaceId::new(),
target: crate::core::session::RemoteTarget::Wsl {
distro: "Ubuntu-22.04".into(),
},
spec: None,
};
let route = PaneRoute::for_workspace(Some(&ws));
let header = route.header().expect("WSL is routed");
assert_eq!(header.describe(), "wsl Ubuntu-22.04");
assert_eq!(header.channel, crate::daemon::router::RouteChannel::Pane);
}
#[test]
fn a_local_stdio_workspace_routes_to_a_child_process_on_the_pane_dialect() {
let ws = PaneWorkspace {
workspace: crate::core::session::WorkspaceId::new(),
target: crate::core::session::RemoteTarget::LocalStdio {
program: "/tmp/tty7-server".into(),
args: vec!["--stdio".into()],
},
spec: None,
};
let route = PaneRoute::for_workspace(Some(&ws));
let header = route.header().expect("a local child is routable");
assert_eq!(header.channel, crate::daemon::router::RouteChannel::Pane);
match &header.target {
crate::daemon::router::RouteTarget::LocalStdio { program, args } => {
assert_eq!(program, "/tmp/tty7-server");
assert_eq!(args, &vec!["--stdio".to_string(), "--pane".to_string()]);
}
other => panic!("wrong target: {other:?}"),
}
}
#[test]
fn an_unroutable_workspace_is_not_treated_as_local() {
let ws = PaneWorkspace {
workspace: crate::core::session::WorkspaceId::new(),
target: crate::core::session::RemoteTarget::Alias {
alias: "build-box".into(),
},
spec: None,
};
let route = PaneRoute::for_workspace(Some(&ws));
assert!(matches!(route, PaneRoute::Unroutable(_)));
assert!(route.header().is_none(), "nothing to route to");
let err = connect_routed(&route).expect_err("must not reach the local daemon");
assert!(err.to_string().contains("cannot be routed"), "{err}");
}
#[test]
fn only_a_local_pane_may_restart_the_local_daemon() {
assert!(PaneRoute::Local.is_local());
assert!(PaneRoute::for_workspace(None).is_local());
assert!(
!PaneRoute::for_workspace(Some(&ssh_workspace())).is_local(),
"a routed pane's disconnect is the remote's failure, not the local daemon's"
);
assert!(
!PaneRoute::Unroutable("no ssh details".into()).is_local(),
"nothing was ever asked of the local daemon"
);
}
#[test]
fn kitty_keyboard_negotiation_reports_the_requested_mode() {
let config = terminal_config_from_user(&crate::core::config::Config::default());
assert!(config.kitty_keyboard);
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
DaemonMsg::Output(b"\x1b[>7u\x1b[?u".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let mut reply = None;
for _ in 0..200 {
while let Ok(event) = term.events.try_recv() {
if let AlacEvent::PtyWrite(text) = event {
reply = Some(text);
}
}
if reply.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(reply.as_deref(), Some("\x1b[?7u"));
assert!(
term.term
.lock()
.mode()
.contains(TermMode::DISAMBIGUATE_ESC_CODES)
);
}
#[test]
fn deep_keyboard_mode_pushes_leave_the_reader_alive() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
let mut payload = b"\x1b[>1u".repeat(4097);
payload.extend_from_slice(b"\x1b[?u");
DaemonMsg::Output(payload).encode(&mut daemon_side).unwrap();
daemon_side.flush().unwrap();
let mut reply = None;
for _ in 0..200 {
while let Ok(event) = term.events.try_recv() {
if let AlacEvent::PtyWrite(text) = event {
reply = Some(text);
}
}
if reply.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(
reply.as_deref(),
Some("\x1b[?1u"),
"the reader thread must survive a deep mode-push run and still answer queries"
);
}
#[test]
fn emoji_presentation_sequences_reserve_two_columns() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
DaemonMsg::Output("\u{2764}\u{FE0F}x".as_bytes().to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let mut row = String::new();
for _ in 0..200 {
{
let t = term.term.lock();
let grid = t.grid();
row.clear();
for col in 0..3usize {
row.push(
grid[alacritty_terminal::index::Line(0)]
[alacritty_terminal::index::Column(col)]
.c,
);
}
}
if row.contains('x') {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(
row, "\u{2764} x",
"❤️ must hold two columns (glyph + spacer) before the next glyph"
);
}
#[test]
fn spawn_retry_only_for_daemon_disconnects() {
let eof: anyhow::Error =
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "closed").into();
assert!(daemon_disconnected_before_spawn_reply(&eof));
let refused = anyhow::anyhow!("daemon refused Spawn: configured shell missing");
assert!(!daemon_disconnected_before_spawn_reply(&refused));
}
#[test]
fn only_a_dead_daemon_is_worth_starting_one_for() {
let connect_failed = |kind| -> anyhow::Error {
anyhow::Error::new(std::io::Error::new(kind, "no listener"))
.context("connect to daemon at /tmp/tty7.sock")
};
assert!(daemon_not_listening(&connect_failed(
std::io::ErrorKind::ConnectionRefused
)));
assert!(daemon_not_listening(&connect_failed(
std::io::ErrorKind::NotFound
)));
let refused = anyhow::anyhow!("daemon refused Spawn: configured shell missing");
assert!(!daemon_not_listening(&refused));
let eof: anyhow::Error =
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "closed").into();
assert!(!daemon_not_listening(&eof));
}
#[test]
fn an_attach_to_a_missing_pane_is_an_error_not_a_disconnect() {
let (mut client_side, mut daemon_side) = UnixStream::pair().unwrap();
DaemonMsg::Error("no such pane 7".to_string())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let err = attach_reply_prefix(&mut client_side, 7, attach_reply_wait(&PaneRoute::Local))
.expect_err("a missing pane must fail");
assert!(
format!("{err:#}").contains("no such pane 7"),
"the daemon's own wording is what names which pane went: {err:#}"
);
}
#[test]
fn an_attach_the_daemon_hangs_up_on_is_an_error() {
let (mut client_side, daemon_side) = UnixStream::pair().unwrap();
drop(daemon_side);
assert!(
attach_reply_prefix(&mut client_side, 7, attach_reply_wait(&PaneRoute::Local)).is_err()
);
}
#[test]
fn a_local_attach_does_not_wait_as_long_as_a_remote_one() {
let local = attach_reply_wait(&PaneRoute::Local);
let remote = attach_reply_wait(&PaneRoute::for_workspace(Some(&ssh_workspace())));
assert!(local < remote, "{local:?} must be the shorter wait");
assert!(
local <= std::time::Duration::from_secs(2),
"the UI thread is holding still for this"
);
}
#[test]
fn a_live_attach_hands_its_replay_bytes_to_the_reader() {
crate::core::config::pin_test_config_dir();
let (mut client_side, mut daemon_side) = UnixStream::pair().unwrap();
DaemonMsg::Snapshot(b"hello".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let buffered =
attach_reply_prefix(&mut client_side, 7, attach_reply_wait(&PaneRoute::Local))
.expect("a live pane attaches");
assert!(
!buffered.is_empty(),
"the classification read the Snapshot frame; it must come back"
);
let term =
RemoteTerminal::from_stream_with(client_side, TermSize::new(80, 24), buffered).unwrap();
let mut got = String::new();
for _ in 0..200 {
{
let t = term.term.lock();
let grid = t.grid();
got.clear();
for col in 0..5usize {
got.push(
grid[alacritty_terminal::index::Line(0)]
[alacritty_terminal::index::Column(col)]
.c,
);
}
}
if got == "hello" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(
got, "hello",
"the pre-read replay must still reach the grid"
);
}
#[test]
fn reader_feeds_local_grid() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let size = TermSize::new(80, 24);
let term = RemoteTerminal::from_stream(client_side, size).unwrap();
DaemonMsg::Output(b"hello".to_vec())
.encode(&mut daemon_side)
.unwrap();
DaemonMsg::Cwd(PathBuf::from("/tmp/work"))
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let mut got = String::new();
for _ in 0..200 {
{
let t = term.term.lock();
let grid = t.grid();
got.clear();
for col in 0..5usize {
let cell = &grid[alacritty_terminal::index::Line(0)]
[alacritty_terminal::index::Column(col)];
got.push(cell.c);
}
}
if got == "hello" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(got, "hello", "reader thread should have fed the grid");
let mut cwd = None;
for _ in 0..200 {
cwd = term.foreground_cwd();
if cwd.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(cwd, Some(PathBuf::from("/tmp/work")));
drop(daemon_side);
for _ in 0..200 {
if term.exited_flag.load(Ordering::SeqCst) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(term.exited_flag.load(Ordering::SeqCst));
}
#[test]
fn cursor_style_sequence_overrides_and_resets_to_user_default() {
use alacritty_terminal::vte::ansi::CursorShape;
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
let mut user_config = crate::core::config::Config::default();
user_config.cursor_style = ConfigCursorStyle::Underline;
term.apply_user_config(&user_config);
let mut shape = term.term.lock().cursor_style().shape;
assert_eq!(shape, CursorShape::Underline);
DaemonMsg::Output(b"\x1b[6 q".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
for _ in 0..200 {
shape = term.term.lock().cursor_style().shape;
if shape == CursorShape::Beam {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(shape, CursorShape::Beam);
DaemonMsg::Output(b"\x1b[0 q".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
for _ in 0..200 {
shape = term.term.lock().cursor_style().shape;
if shape == CursorShape::Underline {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(shape, CursorShape::Underline);
}
#[test]
fn reader_surfaces_auth_prompt_and_status() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
DaemonMsg::SshStatus {
phase: SshPhase::Authenticating,
}
.encode(&mut daemon_side)
.unwrap();
DaemonMsg::AuthPrompt {
request_id: 7,
prompt: AuthPromptKind::Password {
user: "deploy".into(),
host: "10.0.0.5".into(),
},
}
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let mut prompt = None;
for _ in 0..200 {
if let Some(p) = term.take_auth_prompt() {
prompt = Some(p);
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let (id, kind) = prompt.expect("auth prompt should have surfaced");
assert_eq!(id, 7);
assert!(matches!(kind, AuthPromptKind::Password { .. }));
assert_eq!(term.ssh_phase(), Some(SshPhase::Authenticating));
assert!(!term.has_pending_auth());
}
#[test]
fn child_exit_is_distinguished_from_daemon_disconnect() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
DaemonMsg::Exited { code: Some(0) }
.encode(&mut daemon_side)
.unwrap();
for _ in 0..200 {
if term.exited_flag.load(Ordering::SeqCst) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(term.exited_flag.load(Ordering::SeqCst));
assert!(
term.child_exited(),
"an Exited frame is a genuine child exit"
);
let (client_side, daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
drop(daemon_side);
for _ in 0..200 {
if term.exited_flag.load(Ordering::SeqCst) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(term.exited_flag.load(Ordering::SeqCst));
assert!(
!term.child_exited(),
"a disconnect is not a child exit — auto-close must not fire"
);
}
#[test]
fn stale_mode_resets_target_only_the_dirty_bits() {
let clean = TermMode::SHOW_CURSOR | TermMode::LINE_WRAP | TermMode::BRACKETED_PASTE;
assert!(stale_mode_resets(clean).is_empty());
let hidden = TermMode::LINE_WRAP;
assert_eq!(stale_mode_resets(hidden), b"\x1b[?25h");
let residue = TermMode::ALT_SCREEN | TermMode::MOUSE_DRAG | TermMode::SGR_MOUSE;
let seq = stale_mode_resets(residue);
let text = String::from_utf8_lossy(&seq).into_owned();
assert!(text.starts_with("\x1b[?1049l"));
assert!(text.contains("\x1b[?25h"));
assert!(text.contains("\x1b[?1002l"));
assert!(text.contains("\x1b[?1006l"));
assert!(text.ends_with("\x1b[=0;1u"));
let kitty = TermMode::SHOW_CURSOR | TermMode::DISAMBIGUATE_ESC_CODES;
assert_eq!(stale_mode_resets(kitty), b"\x1b[=0;1u");
}
#[test]
fn prompt_report_scrubs_stale_tui_modes() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
DaemonMsg::Output(b"\x1b[?1049h\x1b[?25l\x1b[?1002h\x1b[?1006h".to_vec())
.encode(&mut daemon_side)
.unwrap();
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: Some(255),
}
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let mut mode = TermMode::NONE;
for _ in 0..200 {
mode = *term.term.lock().mode();
let scrubbed = !mode.contains(TermMode::ALT_SCREEN)
&& mode.contains(TermMode::SHOW_CURSOR)
&& !mode.intersects(TermMode::MOUSE_MODE)
&& !mode.contains(TermMode::SGR_MOUSE);
if scrubbed && term.at_prompt() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(
!mode.contains(TermMode::ALT_SCREEN),
"the prompt report must pull the grid off the stranded alt screen"
);
assert!(
mode.contains(TermMode::SHOW_CURSOR),
"the prompt report must re-show the DECTCEM-hidden cursor"
);
assert!(
!mode.intersects(TermMode::MOUSE_MODE) && !mode.contains(TermMode::SGR_MOUSE),
"the prompt report must disable stale mouse reporting"
);
}
#[test]
fn snapshot_replay_suppresses_query_replies_and_side_effects() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
DaemonMsg::Snapshot(b"\x1b[6n\x1b]11;?\x07\x1b]52;c;aGk=\x07\x07replayed".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let mut events = Vec::new();
for _ in 0..200 {
while let Ok(ev) = term.events.try_recv() {
events.push(ev);
}
if events.iter().any(|e| matches!(e, AlacEvent::Wakeup)) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(
events.iter().any(|e| matches!(e, AlacEvent::Wakeup)),
"the replay's Wakeup should still arrive"
);
assert!(
!events.iter().any(|e| matches!(
e,
AlacEvent::PtyWrite(_)
| AlacEvent::ColorRequest(..)
| AlacEvent::ClipboardStore(..)
| AlacEvent::ClipboardLoad(..)
| AlacEvent::Bell
)),
"replayed history must not re-answer queries or replay side effects"
);
DaemonMsg::Output(b"\x1b[6n".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let mut got_reply = false;
for _ in 0..200 {
while let Ok(ev) = term.events.try_recv() {
if matches!(ev, AlacEvent::PtyWrite(_)) {
got_reply = true;
}
}
if got_reply {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(got_reply, "live queries must still be answered");
}
#[test]
fn decrqm_probe_reports_sync_update_supported() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
DaemonMsg::Output(b"\x1b[?2026$p".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let mut reply = None;
for _ in 0..200 {
while let Ok(ev) = term.events.try_recv() {
if let AlacEvent::PtyWrite(text) = ev {
reply = Some(text);
}
}
if reply.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(
reply.as_deref(),
Some("\x1b[?2026;2$y"),
"DECRQM ?2026 must be answered as supported (2 = reset)"
);
}
#[test]
fn win_size_carries_grid_and_cell_dims() {
let ws = win_size(TermSize::new(80, 24), 8, 17);
assert_eq!(ws.cols, 80);
assert_eq!(ws.rows, 24);
assert_eq!(ws.cell_w, 8);
assert_eq!(ws.cell_h, 17);
}
#[test]
fn write_sends_input_frames() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
term.write(Vec::<u8>::new());
term.write(b"echo hi\r".to_vec());
match ClientMsg::read(&mut daemon_side).unwrap() {
ClientMsg::Input(bytes) => assert_eq!(bytes, b"echo hi\r"),
other => panic!("expected Input, got {other:?}"),
}
}
#[test]
fn attach_replay_runs_at_the_daemon_reported_size() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
DaemonMsg::Size(WinSize {
cols: 120,
rows: 30,
cell_w: 8,
cell_h: 17,
})
.encode(&mut daemon_side)
.unwrap();
DaemonMsg::Snapshot(vec![b'x'; 100])
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let (mut tail, mut wrapped) = (' ', ' ');
for _ in 0..200 {
{
use alacritty_terminal::grid::Dimensions as _;
let t = term.term.lock();
let grid = t.grid();
if grid.columns() >= 120 {
tail = grid[alacritty_terminal::index::Line(0)]
[alacritty_terminal::index::Column(99)]
.c;
wrapped = grid[alacritty_terminal::index::Line(1)]
[alacritty_terminal::index::Column(0)]
.c;
}
}
if tail == 'x' {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(tail, 'x', "replay should run at the recorded 120-col width");
assert_eq!(
wrapped, ' ',
"a 100-char line must not wrap on a 120-col grid"
);
}
#[test]
fn first_resize_always_syncs_then_dedups() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let mut term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
term.resize(TermSize::new(80, 24), 8, 17);
match ClientMsg::read(&mut daemon_side).unwrap() {
ClientMsg::Resize(ws) => assert_eq!((ws.cols, ws.rows), (80, 24)),
other => panic!("expected the first Resize to be sent, got {other:?}"),
}
term.resize(TermSize::new(80, 24), 8, 17);
term.write(b"marker".to_vec());
match ClientMsg::read(&mut daemon_side).unwrap() {
ClientMsg::Input(bytes) => assert_eq!(bytes, b"marker"),
other => panic!("expected Input (dup resize sends nothing), got {other:?}"),
}
}
#[test]
fn sync_update_without_esu_flushes_after_the_deadline() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
DaemonMsg::Output(b"\x1b[?2026habc".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let mut got = String::new();
for _ in 0..600 {
{
let t = term.term.lock();
let grid = t.grid();
got.clear();
for col in 0..3usize {
got.push(
grid[alacritty_terminal::index::Line(0)]
[alacritty_terminal::index::Column(col)]
.c,
);
}
}
if got == "abc" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(got, "abc", "dangling BSU must flush on the sync deadline");
}
#[test]
fn snapshot_replay_flushes_a_dangling_sync_frame_suppressed() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
DaemonMsg::Snapshot(b"\x1b[?2026h\x1b[6nhi".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let mut got = String::new();
for _ in 0..200 {
{
let t = term.term.lock();
let grid = t.grid();
got.clear();
for col in 0..2usize {
got.push(
grid[alacritty_terminal::index::Line(0)]
[alacritty_terminal::index::Column(col)]
.c,
);
}
}
if got == "hi" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(
got, "hi",
"the trapped replay tail must flush with the snapshot"
);
let mut events = Vec::new();
while let Ok(ev) = term.events.try_recv() {
events.push(ev);
}
assert!(
!events.iter().any(|e| matches!(e, AlacEvent::PtyWrite(_))),
"a query inside the replayed sync tail must stay suppressed"
);
}
#[test]
fn layout_resize_reasserts_geometry_after_a_late_size_frame() {
use alacritty_terminal::grid::Dimensions as _;
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let mut term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
term.resize(TermSize::new(100, 40), 8, 17);
assert!(matches!(
ClientMsg::read(&mut daemon_side).unwrap(),
ClientMsg::Resize(_)
));
DaemonMsg::Size(WinSize {
cols: 120,
rows: 30,
cell_w: 8,
cell_h: 17,
})
.encode(&mut daemon_side)
.unwrap();
DaemonMsg::Snapshot(b"old screen".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
for _ in 0..200 {
if term.term.lock().columns() == 120 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(term.term.lock().columns(), 120, "replay geometry applied");
term.resize(TermSize::new(100, 40), 8, 17);
assert_eq!(term.term.lock().columns(), 100);
assert_eq!(term.term.lock().screen_lines(), 40);
assert!(matches!(
ClientMsg::read(&mut daemon_side).unwrap(),
ClientMsg::Resize(ws) if ws.cols == 100 && ws.rows == 40
));
}
#[test]
fn resize_updates_size_and_notifies_daemon() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let mut term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
term.resize(TermSize::new(100, 40), 9, 18);
assert_eq!(term.size(), TermSize::new(100, 40));
match ClientMsg::read(&mut daemon_side).unwrap() {
ClientMsg::Resize(ws) => {
assert_eq!((ws.cols, ws.rows, ws.cell_w, ws.cell_h), (100, 40, 9, 18));
}
other => panic!("expected Resize, got {other:?}"),
}
}
#[test]
fn at_prompt_stays_false_while_shell_integration_is_inactive() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
assert!(!term.shell_active(), "no report yet → integration inactive");
DaemonMsg::Prompt {
active: false,
at_prompt: true,
last_exit: None,
}
.encode(&mut daemon_side)
.unwrap();
DaemonMsg::Output(b"m".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let mut synced = false;
for _ in 0..200 {
let c = term.term.lock().grid()[alacritty_terminal::index::Line(0)]
[alacritty_terminal::index::Column(0)]
.c;
if c == 'm' {
synced = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(synced, "reader should have applied both frames");
assert!(!term.shell_active());
assert!(!term.at_prompt(), "inactive shell must gate at_prompt off");
}
#[test]
fn at_prompt_follows_daemon_prompt_reports() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
assert!(!term.at_prompt());
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: Some(0),
}
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let mut at = false;
for _ in 0..200 {
if term.at_prompt() {
at = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(at, "at_prompt should become true after the Prompt report");
}
#[test]
fn foreground_agent_follows_daemon_agent_reports() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
assert_eq!(term.foreground_agent(), None, "none before any report");
let poll = |want: Option<CLIAgent>| {
for _ in 0..200 {
if term.foreground_agent() == want {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
false
};
DaemonMsg::Agent(Some(CLIAgent::Claude))
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
assert!(poll(Some(CLIAgent::Claude)), "agent report should surface");
DaemonMsg::Agent(None).encode(&mut daemon_side).unwrap();
daemon_side.flush().unwrap();
assert!(poll(None), "agent exit should clear it");
}
#[test]
fn agent_session_follows_daemon_status_reports() {
use crate::core::cli_agent::{AgentSessionState, AgentStatus};
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
assert_eq!(term.agent_session(), None, "none before any report");
let poll = |want: &dyn Fn(Option<AgentSessionState>) -> bool| {
for _ in 0..200 {
if want(term.agent_session()) {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
false
};
DaemonMsg::AgentStatus(Some(AgentSessionState {
status: AgentStatus::Waiting,
message: Some("Claude needs your permission".into()),
session_id: Some("sid-1".into()),
launch_argv: None,
rich: true,
cwd: None,
activity: 0,
}))
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
assert!(
poll(&|s| s.is_some_and(|s| s.status == AgentStatus::Waiting
&& s.session_id.as_deref() == Some("sid-1")
&& s.rich)),
"status report should surface with message + session id"
);
DaemonMsg::AgentStatus(None)
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
assert!(poll(&|s| s.is_none()), "a None report clears the session");
}
#[test]
fn marks_record_the_row_each_one_landed_on() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
let poll = |want: usize| {
for _ in 0..200 {
if term.marks().list().len() == want {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
false
};
let mut stream = Vec::new();
stream.extend_from_slice(b"\x1b]133;A\x07");
stream.extend_from_slice(b"\x1b]133;C;echo one\x07");
stream.extend_from_slice(b"one\r\n");
stream.extend_from_slice(b"\x1b]133;D;0\x07");
stream.extend_from_slice(b"\x1b]133;A\x07");
stream.extend_from_slice(b"\x1b]133;C;false\x07");
stream.extend_from_slice(b"\r\n");
stream.extend_from_slice(b"\x1b]133;D;1\x07");
DaemonMsg::Output(stream).encode(&mut daemon_side).unwrap();
daemon_side.flush().unwrap();
assert!(poll(2), "both commands recorded");
let marks = term.marks().list();
assert_eq!(marks[0].text, "echo one");
assert_eq!(marks[0].exit, Some(0));
assert_eq!(marks[1].text, "false");
assert_eq!(marks[1].exit, Some(1), "a failure keeps its exit code");
assert!(
marks[1].row > marks[0].row,
"the second prompt is further down the scrollback ({} vs {}) — equal rows \
would mean the advance wasn't split at the marks",
marks[0].row,
marks[1].row
);
}
#[test]
fn zle_reading_follows_live_prompt_end_marks() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
let poll = |want: bool| {
for _ in 0..200 {
if term.zle_reading() == want {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
false
};
assert!(!term.zle_reading(), "conservative false before any mark");
DaemonMsg::Snapshot(b"\x1b]133;B\x07".to_vec())
.encode(&mut daemon_side)
.unwrap();
DaemonMsg::Output(b"\x1b]133;D;0\x07m".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let mut synced = false;
for _ in 0..200 {
let c = term.term.lock().grid()[alacritty_terminal::index::Line(0)]
[alacritty_terminal::index::Column(0)]
.c;
if c == 'm' {
synced = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(synced, "reader should have applied both frames");
assert!(
!term.zle_reading(),
"replayed B / live D must not arm the flag"
);
DaemonMsg::Output(b"\x1b]133;B\x07".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
assert!(poll(true), "live B should arm zle_reading");
DaemonMsg::Output(b"\x1b]133;C\x07".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
assert!(poll(false), "C (command start) should disarm zle_reading");
}
#[test]
fn shell_vi_mode_follows_live_prompt_mode_marks_without_disarming_zle() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
let poll = |vi: bool, zle: bool| {
for _ in 0..200 {
if term.shell_vi_mode() == vi && term.zle_reading() == zle {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
false
};
assert!(!term.shell_vi_mode(), "conservative false before any mark");
assert!(!term.zle_reading(), "zle also starts false");
DaemonMsg::Output(b"\x1b]133;B\x07".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
assert!(poll(false, true), "B should arm zle only");
DaemonMsg::Output(b"\x1b]133;V;1\x07".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
assert!(
poll(true, true),
"V;1 should set shell vi-mode without disarming zle"
);
DaemonMsg::Output(b"\x1b]133;V;0\x07".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
assert!(
poll(false, true),
"V;0 should clear shell vi-mode without disarming zle"
);
DaemonMsg::Output(b"\x1b]133;C\x07".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
assert!(poll(false, false), "C still disarms zle");
}
#[test]
fn shell_vi_mode_is_restored_from_snapshot_replay() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
let poll = |vi: bool| {
for _ in 0..200 {
if term.shell_vi_mode() == vi {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
false
};
DaemonMsg::Snapshot(b"\x1b]133;V;1\x07".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
assert!(
poll(true),
"attached clients should inherit the prompt's vi-mode state"
);
assert!(
!term.zle_reading(),
"historical replay must not imply zle is currently reading"
);
DaemonMsg::Snapshot(b"\x1b]133;V;0\x07".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
assert!(poll(false), "a replayed V;0 should clear vi-mode state");
}
fn full_dump(term: &RemoteTerminal) -> String {
use alacritty_terminal::grid::Dimensions as _;
let t = term.term.lock();
let grid = t.grid();
let mut out = String::new();
for l in -(grid.history_size() as i32)..grid.screen_lines() as i32 {
for c in 0..grid.columns() {
out.push(
grid[alacritty_terminal::index::Line(l)][alacritty_terminal::index::Column(c)]
.c,
);
}
out.push('\n');
}
out
}
fn tui_frame(lines: &[String], prev_rows: usize) -> Vec<u8> {
let mut b = Vec::new();
if prev_rows > 1 {
b.extend_from_slice(format!("\r\x1b[{}A\x1b[J", prev_rows - 1).as_bytes());
}
b.extend_from_slice(lines.join("\r\n").as_bytes());
b
}
#[test]
fn segmented_ring_replay_reproduces_live_rendering() {
const MARK: &str = "DUPMARK";
let frame_lines = |f: usize| -> Vec<String> {
(0..10)
.map(|i| format!("{MARK} f{f:02} l{i:02} {:.<74}", ""))
.collect()
};
let mut history = Vec::new();
for f in 0..8 {
history.extend(tui_frame(&frame_lines(f), if f == 0 { 0 } else { 10 }));
}
let wait_for = |term: &RemoteTerminal, needle: &str| -> String {
let mut dump = String::new();
for _ in 0..400 {
dump = full_dump(term);
if dump.contains(needle) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
dump
};
let ws = |cols: u16| WinSize {
cols,
rows: 24,
cell_w: 8,
cell_h: 17,
};
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let mut live = RemoteTerminal::from_stream(client_side, TermSize::new(100, 24)).unwrap();
DaemonMsg::Output(history.clone())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let dump = wait_for(&live, "f07 l09");
assert!(dump.contains("f07 l09"), "live output should have landed");
live.resize(TermSize::new(80, 24), 8, 17);
let live_count = full_dump(&live).matches(MARK).count();
assert_eq!(
live_count, 10,
"live rendering is clean: each redraw erases the previous frame, \
so exactly one 10-line copy survives the resize"
);
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let replay = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
DaemonMsg::Size(ws(100)).encode(&mut daemon_side).unwrap();
DaemonMsg::Snapshot(history.clone())
.encode(&mut daemon_side)
.unwrap();
DaemonMsg::Size(ws(80)).encode(&mut daemon_side).unwrap();
DaemonMsg::Snapshot(Vec::new())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let dump = wait_for(&replay, "f07 l09");
{
use alacritty_terminal::grid::Dimensions as _;
let mut cols = 0;
for _ in 0..400 {
cols = replay.term.lock().columns();
if cols == 80 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(cols, 80, "the trailing pair must end the grid at 80 cols");
}
let replay_count = dump.matches(MARK).count();
assert_eq!(
replay_count, live_count,
"the segmented replay must reproduce the live rendering exactly"
);
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let flat = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
DaemonMsg::Size(ws(80)).encode(&mut daemon_side).unwrap();
DaemonMsg::Snapshot(history)
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
let dump = wait_for(&flat, "f07 l09");
let flat_count = dump.matches(MARK).count();
assert!(
flat_count > live_count,
"flat replay at the final width should duplicate (got {flat_count}); \
if it stopped, the segmented path may no longer be exercising anything"
);
}
}
#[cfg(test)]
mod osc_tests {
use super::{OscNotifyScanner, parse_osc_notification};
fn scan(chunks: &[&[u8]]) -> Vec<(Option<String>, String)> {
let mut s = OscNotifyScanner::default();
let mut out = Vec::new();
for c in chunks {
s.feed(c, &mut out);
}
out
}
#[test]
fn osc9_bel_and_st_terminators() {
assert_eq!(
scan(&[b"\x1b]9;Build done\x07"]),
vec![(None, "Build done".to_string())]
);
assert_eq!(
scan(&[b"\x1b]9;Tests passed\x1b\\"]),
vec![(None, "Tests passed".to_string())]
);
}
#[test]
fn osc777_notify_title_and_body() {
assert_eq!(
scan(&[b"\x1b]777;notify;Title;Body text\x07"]),
vec![(Some("Title".to_string()), "Body text".to_string())]
);
assert_eq!(
scan(&[b"\x1b]777;notify;Just a message\x1b\\"]),
vec![(None, "Just a message".to_string())]
);
}
#[test]
fn split_across_reads_is_reassembled() {
assert_eq!(
scan(&[b"\x1b]9;Hel", b"lo wor", b"ld\x07"]),
vec![(None, "Hello world".to_string())]
);
assert_eq!(
scan(&[b"\x1b]9;Ping\x1b", b"\\"]),
vec![(None, "Ping".to_string())]
);
}
#[test]
fn uninteresting_osc_is_ignored_cheaply() {
assert_eq!(
scan(&[b"\x1b]52;c;bWFueSBieXRlcw==\x07\x1b]0;my title\x07"]),
vec![]
);
assert_eq!(
scan(&[b"\x1b]0;title\x07\x1b]9;After\x07"]),
vec![(None, "After".to_string())]
);
}
#[test]
fn conemu_osc9_subcommands_are_not_notifications() {
assert_eq!(scan(&[b"\x1b]9;4;1;50\x07"]), vec![]);
assert_eq!(scan(&[b"\x1b]9;9;/home/u\x07"]), vec![]);
}
#[test]
fn parse_rejects_empty_and_unrelated() {
assert_eq!(parse_osc_notification(b"9;"), None);
assert_eq!(parse_osc_notification(b"777;notify;"), None);
assert_eq!(parse_osc_notification(b"8;;https://example.com"), None);
}
#[test]
fn resyncs_on_new_osc_after_an_unterminated_one() {
assert_eq!(
scan(&[b"\x1b]9;dropped\x1b]9;kept\x07"]),
vec![(None, "kept".to_string())]
);
assert_eq!(
scan(&[b"\x1b]0;title\x1b]9;After title\x07"]),
vec![(None, "After title".to_string())]
);
}
}