Skip to main content

term_session_client/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod remote_pane;
4
5pub use remote_pane::RemotePane;
6
7use std::io::{self, IsTerminal, Write, stdout};
8use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::Duration;
11
12use crossterm::QueueableCommand;
13use crossterm::cursor::{Hide, Show};
14use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste};
15use crossterm::terminal::{
16    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
17};
18use muxio_rpc_service_endpoint::RpcServiceEndpointInterface;
19use muxio_tokio_mpsc_adapter::ChannelCallerExt;
20use muxio_tokio_rpc_ipc_client::{RpcCallPrebuffered, RpcIpcClient, RpcServiceCallerInterface};
21use portable_pty::PtySize;
22use term_clipboard::{Clipboard, Osc52Extractor};
23use term_session_muxio_service_definitions::{
24    Attach, AttachRequest, OnPtyResized, OnWorkspaceRebind, RpcMethodPrebuffered,
25    STREAM_INPUT_METHOD_ID, SUBSCRIBE_OUTPUT_METHOD_ID, Spawn, SpawnRequest, SpawnResponse,
26    path_wire,
27};
28#[cfg(unix)]
29use term_sys_io::redirect_fd_to_tracing;
30use term_wm_events::{Event, KeyKind, KeyModifiers, MouseEventKind};
31use term_wm_pty_engine::Pane;
32use term_wm_pty_engine::input_encoding::{key_to_bytes, mouse_event_to_bytes};
33use term_wm_pty_engine::signal::install_sigint_handler;
34use term_wm_vt100::{MouseProtocolEncoding, MouseProtocolMode, Parser, Screen};
35
36/// Disable Windows console "QuickEdit" mode so mouse clicks select nothing and
37/// never suspend the console's output. Best-effort: console-less sessions
38/// (CI, redirected stdio) simply no-op.
39#[cfg(windows)]
40fn disable_quick_edit() {
41    use windows_sys::Win32::System::Console::{
42        ENABLE_EXTENDED_FLAGS, ENABLE_QUICK_EDIT_MODE, GetConsoleMode, GetStdHandle,
43        STD_INPUT_HANDLE, SetConsoleMode,
44    };
45
46    unsafe {
47        let handle = GetStdHandle(STD_INPUT_HANDLE);
48        let mut mode: u32 = 0;
49        if GetConsoleMode(handle, &mut mode) != 0 {
50            let _ = SetConsoleMode(
51                handle,
52                (mode & !ENABLE_QUICK_EDIT_MODE) | ENABLE_EXTENDED_FLAGS,
53            );
54        }
55    }
56}
57
58/// Number of iterations to wait for initial PTY output.
59/// Windows ConPTY needs more time to initialize and flush its internal buffers.
60#[cfg(target_os = "windows")]
61const INITIAL_WAIT_ITERS: usize = 60;
62#[cfg(not(target_os = "windows"))]
63const INITIAL_WAIT_ITERS: usize = 20;
64
65/// Maximum buffered PTY output frames before backpressure kicks in.
66const PTY_OUTPUT_CHANNEL_CAPACITY: usize = 256;
67/// Maximum buffered clipboard events (human-driven, small capacity is fine).
68const CLIPBOARD_CHANNEL_CAPACITY: usize = 64;
69/// Maximum buffered input events (covers paste bursts without over-allocating).
70const INPUT_CHANNEL_CAPACITY: usize = 64;
71
72/// Number of trailing bytes retained to detect OSC 52 clipboard sequences
73/// that straddle chunk boundaries.  8 bytes is enough to hold the longest
74/// OSC 52 tail (the BEL terminator and preceding data).
75const PREV_TAIL_LEN: usize = 8;
76
77/// Sleep duration (ms) between iterations while waiting for initial PTY output.
78const INITIAL_WAIT_SLEEP_MS: u64 = 50;
79
80/// Crossterm input polling interval (ms).  Short enough for responsive
81/// input, long enough to keep CPU idle when nothing is happening.
82const INPUT_POLL_MS: u64 = 50;
83
84/// Sleep duration (ms) in the output-backpressure loop when the PTY
85/// output channel is saturated.
86const BACKPRESSURE_SLEEP_MS: u64 = 1;
87
88/// Extra allocation headroom for bracketed-paste wrapper sequences:
89/// 6 bytes for `\x1b[200~` + 6 bytes for `\x1b[201~`.
90const BRACKETED_PASTE_OVERHEAD: usize = 12;
91
92/// Rough per-cell ANSI byte multiplier for initial render-buffer capacity.
93const RENDER_BUF_CELL_MULTIPLIER: usize = 3;
94
95/// Minimum terminal grid size: the vt100 parser computes `rows - 1` at
96/// construction, so a 0-size grid would overflow. Headless ptys (e.g. under
97/// `script` with `/dev/null`) can report 0x0; clamp to these.
98const MIN_TERM_COLS: u16 = 2;
99const MIN_TERM_ROWS: u16 = 2;
100
101/// Heuristic seed geometry when the terminal size cannot be queried (headless
102/// CI, redirected/`/dev/null` stdio). Overridden by the real attached geometry
103/// at render time; the server clamps to the smallest size across clients.
104const FALLBACK_TERM_COLS: u16 = 80;
105const FALLBACK_TERM_ROWS: u16 = 24;
106
107/// Create a nesting-inception fatal error branded with the given app name,
108/// naming BOTH endpoints so a refusal is instantly diagnosable: the active
109/// session gateway comes from the host daemon's inception marker, the
110/// requested gateway is the socket this client was about to attach to.
111pub fn nested_session_fatal_error(
112    app_name: &str,
113    active_gateway: Option<&str>,
114    target_socket: &str,
115) -> io::Error {
116    io::Error::other(format!(
117        "FATAL: Attempted to run {app_name} inside an existing session environment on the same gateway.\n\
118         Session inception can cause terminal buffer corruption and accidental gateway stops.\n\n\
119           Active session gateway: {}\n  \
120         Requested gateway:       {target_socket}\n\n\
121         This is genuinely the SAME gateway as your enclosing session.\nOptions:\n  \
122         - Run on a different gateway: set TERM_WM_NAMESPACE=<ns> (namespace root)\n    \
123           or pass --gateway <name> (whole path).\n  \
124         - Force nested execution: rerun with:\n      \
125           {app_name} --allow-nested [args...]",
126        active_gateway.unwrap_or("<unset>")
127    ))
128}
129
130/// Returns `true` if an `io::Error` was caused by session inception detection.
131pub fn is_nested_session_fatal(err: &io::Error) -> bool {
132    err.to_string().contains("FATAL: Attempted to run")
133}
134
135/// Whether an attach should be refused because the caller is already inside an
136/// active term-session targeting the same gateway and nesting was not explicitly
137/// allowed.
138fn should_block_nesting(
139    active_gateway: Option<&str>,
140    target_socket: &str,
141    allow_nested: bool,
142) -> bool {
143    if allow_nested {
144        return false;
145    }
146    match active_gateway {
147        Some(active) => active == target_socket,
148        None => false,
149    }
150}
151
152/// Initialize terminal for TUI mode: write startup escape sequences
153/// (alternate screen, hide cursor, bracketed paste, mouse capture) to
154/// the given writer, enable raw mode on stdin, and return a guard that
155/// restores the terminal on drop.
156///
157/// The writer parameter allows tests to capture the ANSI sequences
158/// without writing to a real terminal.
159pub fn init_terminal<W: Write>(mut writer: W) -> io::Result<TerminalGuard<W>> {
160    if std::io::stdin().is_terminal() {
161        enable_raw_mode()?;
162    }
163    writer.queue(EnterAlternateScreen)?;
164    writer.queue(Hide)?;
165    writer.queue(EnableBracketedPaste)?;
166    writer.queue(crossterm::event::EnableMouseCapture)?;
167    // crossterm's `EnableMouseCapture` on Windows only calls `SetConsoleMode`
168    // (`is_ansi_code_supported()` returns false), so it emits no ANSI. When
169    // this client runs inside a host emulator (term-wm via ConPTY), the host
170    // therefore never sees the mouse-enable request and never routes mouse
171    // events back to the client — the mouse is dead. Write the ANSI sequences
172    // explicitly so the host detects mouse tracking and forwards mouse input
173    // (on Unix crossterm already writes these, so this is Windows-only).
174    #[cfg(windows)]
175    term_wm_crossterm_adapter::set_mouse_capture_with(&mut writer, true)?;
176    writer.flush()?;
177    Ok(TerminalGuard {
178        writer: Some(writer),
179    })
180}
181
182/// Guard that restores the terminal (leave alternate screen, show cursor,
183/// disable bracketed paste) when dropped.  Generic over `W` so tests can
184/// inject a `Vec<u8>` writer and verify the teardown sequences.
185pub struct TerminalGuard<W: Write = std::io::Stdout> {
186    writer: Option<W>,
187}
188
189impl<W: Write> Drop for TerminalGuard<W> {
190    fn drop(&mut self) {
191        if let Some(ref mut writer) = self.writer {
192            let _ = writer.queue(crossterm::event::DisableMouseCapture);
193            // Mirror the init fix (Windows-only): emit the ANSI mouse-disable
194            // so a host emulator stops routing mouse input to the client.
195            #[cfg(windows)]
196            let _ = term_wm_crossterm_adapter::set_mouse_capture_with(writer, false);
197            let _ = writer.queue(DisableBracketedPaste);
198            let _ = writer.queue(Show);
199            let _ = writer.queue(LeaveAlternateScreen);
200            if std::io::stdin().is_terminal() {
201                let _ = disable_raw_mode();
202            }
203            let _ = writer.flush();
204        }
205    }
206}
207
208/// Convert a crossterm event into a core Event for use in the event-driven loop.
209fn convert_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
210    term_wm_crossterm_adapter::try_translate_event(evt)
211}
212
213/// Two motion mouse events may be coalesced (keep only the latest position)
214/// only when both the event kind and modifier flags match.  Modifier changes
215/// mid-drag (Shift/Ctrl/Alt pressed or released) must be preserved — they
216/// signal state transitions that terminal applications rely on.
217fn attributed_mouse_is_motion(e: &Event) -> bool {
218    matches!(
219        e,
220        Event::Mouse(m) if matches!(m.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
221    )
222}
223
224/// Whether two events are coalescable mouse motion events (same kind family
225/// and same modifier set — delegates to [`is_coalescable_mouse`]).
226fn attributed_mouse_coalescable(a: &Event, b: &Event) -> bool {
227    match (a, b) {
228        (Event::Mouse(am), Event::Mouse(bm)) => {
229            is_coalescable_mouse(&am.kind, &am.modifiers, &bm.kind, &bm.modifiers)
230        }
231        _ => false,
232    }
233}
234
235fn is_coalescable_mouse(
236    a_kind: &MouseEventKind,
237    a_mod: &KeyModifiers,
238    b_kind: &MouseEventKind,
239    b_mod: &KeyModifiers,
240) -> bool {
241    if a_mod != b_mod {
242        return false;
243    }
244    match (a_kind, b_kind) {
245        (MouseEventKind::Moved, MouseEventKind::Moved) => true,
246        (MouseEventKind::Drag(btn1), MouseEventKind::Drag(btn2)) => btn1 == btn2,
247        _ => false,
248    }
249}
250
251/// OS user running the client process, reported at `Attach` so `list` can show
252/// who each socket belongs to. On Unix the passwd entry is authoritative, with
253/// `$USER` as a fallback; on Windows `%USERNAME%` is used, with `GetUserNameW`
254/// as a fallback for contexts where the env var is unset.
255fn client_user() -> String {
256    #[cfg(unix)]
257    {
258        unsafe {
259            let pw = libc::getpwuid(libc::getuid());
260            if !pw.is_null() {
261                let name = std::ffi::CStr::from_ptr((*pw).pw_name);
262                if let Ok(s) = name.to_str()
263                    && !s.is_empty()
264                {
265                    return s.to_string();
266                }
267            }
268        }
269        std::env::var("USER").unwrap_or_default()
270    }
271    #[cfg(windows)]
272    {
273        if let Ok(u) = std::env::var("USERNAME")
274            && !u.is_empty()
275        {
276            return u;
277        }
278        windows_username().unwrap_or_default()
279    }
280    #[cfg(not(any(unix, windows)))]
281    {
282        String::new()
283    }
284}
285
286/// Windows fallback: resolve the account via `GetUserNameW` when `%USERNAME%`
287/// is not set (e.g. service or non-interactive contexts).
288#[cfg(windows)]
289fn windows_username() -> Option<String> {
290    use std::os::windows::ffi::OsStringExt;
291    use windows_sys::Win32::System::WindowsProgramming::GetUserNameW;
292    let mut buf = [0u16; 256];
293    let mut len = buf.len() as u32;
294    let ok = unsafe { GetUserNameW(buf.as_mut_ptr(), &mut len) };
295    if ok == 0 {
296        return None;
297    }
298    let s = std::ffi::OsString::from_wide(&buf[..len as usize])
299        .to_string_lossy()
300        .into_owned();
301    if s.is_empty() { None } else { Some(s) }
302}
303
304/// Client binary version (`CARGO_PKG_VERSION`), reported at `Attach` so `list`
305/// can surface mixed-version clients against the same daemon.
306fn client_version() -> String {
307    env!("CARGO_PKG_VERSION").to_string()
308}
309
310/// Remote peer IP for SSH attaches: `sshd` sets `SSH_CLIENT` (client ip/port
311/// server-port) or `SSH_CONNECTION` (client ip/port server ip/port); the first
312/// whitespace token is the peer address. Returns `None` for local attaches.
313fn client_ssh_ip() -> Option<String> {
314    for var in ["SSH_CLIENT", "SSH_CONNECTION"] {
315        if let Ok(v) = std::env::var(var) {
316            let ip = v.split_whitespace().next()?;
317            if !ip.is_empty() {
318                return Some(ip.to_string());
319            }
320        }
321    }
322    None
323}
324
325/// SSH client source port from `SSH_CLIENT` (`client-ip client-port server-port`)
326/// or `SSH_CONNECTION` (`client-ip client-port server-ip server-port`).
327/// Returns `None` for local attaches or when the port cannot be parsed.
328fn client_ssh_port() -> Option<u16> {
329    for var in ["SSH_CLIENT", "SSH_CONNECTION"] {
330        if let Ok(v) = std::env::var(var)
331            && let Some(port_str) = v.split_whitespace().nth(1)
332            && let Ok(port) = port_str.parse::<u16>()
333        {
334            return Some(port);
335        }
336    }
337    None
338}
339
340/// Connect to a term-session gateway and run the TUI viewer for `channel`.
341///
342/// This function is synchronous. It creates a background tokio runtime for
343/// muxio IPC, attaches to the gateway channel, spawns/joins the session, then
344/// runs the synchronous crossterm event loop on the calling thread.
345///
346/// `socket_path` is the gateway channel name (the muxio socket identity);
347/// `channel` is the logical channel to attach to; `cmd` is the command to run
348/// (empty = the gateway's default shell). PTY geometry is read from the real
349/// terminal.
350pub fn run_session(
351    socket_path: &str,
352    channel: &str,
353    cmd: &[String],
354    allow_nested: bool,
355    app_name: &str,
356) -> io::Result<Option<String>> {
357    // Reject "session inception": a client started inside an already-active
358    // term-session environment (detected via the marker the daemon injects into
359    // every spawned PTY child). Inception is blocked only when the target socket
360    // matches the host gateway — different gateways are isolated and safe.
361    // `--allow-nested` opts out.
362    let host_gateway =
363        std::env::var(term_session_muxio_service_definitions::SESSION_GATEWAY_ENV_VAR).ok();
364    if should_block_nesting(host_gateway.as_deref(), socket_path, allow_nested) {
365        return Err(nested_session_fatal_error(
366            app_name,
367            host_gateway.as_deref(),
368            socket_path,
369        ));
370    }
371
372    // Windows console hosts default to "QuickEdit" mode: clicking the window
373    // enters text-selection mode, during which the kernel suspends the
374    // process's console I/O until the selection is cleared (Esc). A stray
375    // click then looks exactly like a frozen terminal. Disable it up front.
376    #[cfg(windows)]
377    disable_quick_edit();
378
379    let rt =
380        tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
381
382    // Connect via muxio IPC
383    let client: Arc<RpcIpcClient> = rt
384        .block_on(RpcIpcClient::new(socket_path))
385        .map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, format!("{e:?}")))?;
386
387    // ABI/transport fault interception: a decode/parse fault during the
388    // handshake (e.g. an upgraded client against a legacy daemon on the same
389    // socket) must produce a clear diagnostic, never a panic or silent drop.
390    let abi_fault = |e: &dyn std::fmt::Display| -> io::Error {
391        io::Error::other(format!(
392            "FATAL: Protocol ABI mismatch. A legacy daemon may be occupying the IPC socket. Manually terminate the daemon process before continuing. (cause: {e})"
393        ))
394    };
395
396    // Atomic registers for server-initiated geometry changes (OnPtyResized).
397    // Initialised before Attach/Spawn so the handler is registered before
398    // the server can send any notifications — prevents RpcMethodNotFound.
399    let server_cols = Arc::new(AtomicU16::new(0));
400    let server_rows = Arc::new(AtomicU16::new(0));
401    let resize_pending = Arc::new(AtomicBool::new(false));
402
403    {
404        let cols_ref = Arc::clone(&server_cols);
405        let rows_ref = Arc::clone(&server_rows);
406        let pending_ref = Arc::clone(&resize_pending);
407        rt.block_on(client.get_endpoint().register_prebuffered(
408            OnPtyResized::METHOD_ID,
409            move |payload, _ctx| {
410                let cols_ref = Arc::clone(&cols_ref);
411                let rows_ref = Arc::clone(&rows_ref);
412                let pending_ref = Arc::clone(&pending_ref);
413                async move {
414                    let (cols, rows) = OnPtyResized::decode_request(&payload)
415                        .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
416                    cols_ref.store(cols, Ordering::Relaxed);
417                    rows_ref.store(rows, Ordering::Relaxed);
418                    pending_ref.store(true, Ordering::Relaxed);
419                    OnPtyResized::encode_response(())
420                        .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
421                }
422            },
423        ))
424        .map_err(|e| io::Error::other(format!("register OnPtyResized: {e:?}")))?;
425    }
426
427    // Workspace rebind signal: set by OnWorkspaceRebind handler when the
428    // server tells this viewer to switch to a different channel.
429    let rebind_target: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
430
431    {
432        let target_ref = Arc::clone(&rebind_target);
433        rt.block_on(client.get_endpoint().register_prebuffered(
434            OnWorkspaceRebind::METHOD_ID,
435            move |payload, _ctx| {
436                let target_ref = Arc::clone(&target_ref);
437                async move {
438                    let req = OnWorkspaceRebind::decode_request(&payload)
439                        .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
440                    *target_ref.lock().unwrap_or_else(|err| err.into_inner()) = Some(req.target);
441                    OnWorkspaceRebind::encode_response(())
442                        .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
443                }
444            },
445        ))
446        .map_err(|e| io::Error::other(format!("register OnWorkspaceRebind: {e:?}")))?;
447    }
448
449    // Channels for raw PTY output bytes and clipboard text from the subscription stream.
450    // Using crossbeam so the main loop can block on both input and PTY output.
451    // Bounded to cap head-of-line queuing under burst load.
452    let (push_tx, push_rx) = crossbeam_channel::bounded::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
453    let (clip_tx, clip_rx) = crossbeam_channel::bounded::<String>(CLIPBOARD_CHANNEL_CAPACITY);
454
455    // Terminal geometry comes from the real terminal (no cols/rows are threaded
456    // through the API). The vt100 parser computes `rows - 1` at construction, so
457    // clamp a degenerate 0x0 report up to a non-zero grid rather than panicking.
458    // On Unix with redirected stdio (e.g. `>/dev/null` under CI or tests) the
459    // TIOCGWINSZ ioctl fails and `COLUMNS`/`LINES` are unset, so `size()` errors
460    // — fall back to a seed rather than aborting before Attach/Spawn.
461    let (term_cols, term_rows) = match crossterm::terminal::size() {
462        Ok((c, r)) => (c.max(MIN_TERM_COLS), r.max(MIN_TERM_ROWS)),
463        Err(_) => (FALLBACK_TERM_COLS, FALLBACK_TERM_ROWS),
464    };
465    let hostname = hostname::get()
466        .map(|h| h.to_string_lossy().into_owned())
467        .unwrap_or_else(|_| "unknown".to_string());
468
469    let (actual_cols, actual_rows) = rt.block_on(async {
470        // 1) Attach: bind this connection to the channel (server-assigned
471        // conn_id); report our OS PID so `list` can show which client is which.
472        let conn_id = Attach::call(
473            &*client,
474            AttachRequest {
475                channel: channel.to_string(),
476                hostname,
477                pid: std::process::id() as u64,
478                user: client_user(),
479                version: client_version(),
480                ssh_ip: client_ssh_ip(),
481                ssh_port: client_ssh_port(),
482            },
483        )
484        .await
485        .map_err(|e| abi_fault(&e))?;
486        // 2) Spawn: join/respawn the session (cmd travels via Spawn).
487        let cmd = if cmd.is_empty() {
488            None
489        } else {
490            Some(cmd.to_vec())
491        };
492        // The launch directory is captured here so a newly spawned session
493        // starts in the caller's cwd, not the daemon's. It is encoded
494        // losslessly (platform-native raw bytes, see `path_wire`), so even
495        // non-UTF-8 paths survive the wire byte-for-byte. `None` (current_dir
496        // failing) lets the server fall back to the daemon's cwd.
497        let launch_cwd = std::env::current_dir().ok().map(path_wire::encode_path);
498        let SpawnResponse {
499            id: _session_id,
500            cols: actual_cols,
501            rows: actual_rows,
502            ..
503        } = Spawn::call(
504            &*client,
505            SpawnRequest {
506                cmd,
507                cols: term_cols,
508                rows: term_rows,
509                cwd: launch_cwd,
510            },
511        )
512        .await
513        .map_err(|e| abi_fault(&e))?;
514        let _ = conn_id;
515        Ok::<(u16, u16), io::Error>((actual_cols, actual_rows))
516    })?;
517
518    // Open streaming channels for output subscription and input
519    let writer = rt.block_on(async {
520        // Subscribe to PTY output via the mpsc adapter.
521        // `reader` yields response chunks (raw PTY output bytes).
522        let (_, mut reader) = client
523            .open_channel(SUBSCRIBE_OUTPUT_METHOD_ID, 0)
524            .await
525            .map_err(|e| io::Error::other(format!("subscribe: {e:?}")))?;
526
527        // Forward raw PTY output chunks to push_tx.  Each chunk from the
528        // muxio stream is a complete message — no custom framing needed.
529        // Intercept OSC 52 clipboard sequences before the parser consumes them.
530        rt.spawn(async move {
531            let mut osc52 = Osc52Extractor::new();
532            let mut prev_tail: [u8; PREV_TAIL_LEN] = [0; PREV_TAIL_LEN];
533
534            while let Some(chunk) = reader.recv().await {
535                if let Ok(mut data) = chunk {
536                    if let Some(text) = osc52.push(&data, &prev_tail) {
537                        let _ = clip_tx.try_send(text);
538                    }
539
540                    let n = data.len();
541                    if n >= PREV_TAIL_LEN {
542                        prev_tail.copy_from_slice(&data[n - PREV_TAIL_LEN..n]);
543                    } else if n > 0 {
544                        prev_tail.rotate_left(n);
545                        prev_tail[PREV_TAIL_LEN - n..].copy_from_slice(&data[..n]);
546                    }
547
548                    // Non-blocking push; if saturated, sleep 1ms to allow
549                    // the main loop to drain the channel without CPU spinning.
550                    while let Err(crossbeam_channel::TrySendError::Full(pending)) =
551                        push_tx.try_send(data)
552                    {
553                        data = pending;
554                        tokio::time::sleep(Duration::from_millis(BACKPRESSURE_SLEEP_MS)).await;
555                    }
556                } else {
557                    break;
558                }
559            }
560            // Flush any buffered OSC 52 payload at EOF (Windows ConPTY
561            // consumes the BEL/ST terminator).
562            if let Some(text) = osc52.finish() {
563                let _ = clip_tx.try_send(text);
564            }
565        });
566
567        // Open streaming channel for PTY input.
568        // `writer` accepts keystroke bytes.
569        let (writer, _) = client
570            .open_channel(STREAM_INPUT_METHOD_ID, 0)
571            .await
572            .map_err(|e| io::Error::other(format!("stream input: {e:?}")))?;
573
574        Ok::<_, io::Error>(writer)
575    })?;
576
577    let input_writer = Box::new(move |data: &[u8]| -> io::Result<()> {
578        writer
579            .send(data.to_vec())
580            .map_err(|e| io::Error::other(e.to_string()))?;
581        Ok(())
582    });
583
584    let mut pane = RemotePane::new(
585        1u64,
586        Some(client.clone()),
587        rt.handle().clone(),
588        term_cols,
589        term_rows,
590        push_rx.clone(),
591        input_writer,
592    );
593
594    // Single dedicated worker for attributed input. Replaces per-event
595    // tokio::spawn which caused task queue thrashing and socket contention.
596    let (attributed_tx, mut attributed_rx) = tokio::sync::mpsc::channel::<
597        term_session_muxio_service_definitions::SendAttributedInputRequest,
598    >(1024);
599    {
600        let client_clone = client.clone();
601        rt.spawn(async move {
602            use muxio_rpc_service_caller::prebuffered::RpcCallPrebuffered;
603            use term_session_muxio_service_definitions::SendAttributedInput;
604
605            let mut pending_req = None;
606            loop {
607                let mut req = match pending_req.take() {
608                    Some(r) => r,
609                    None => match attributed_rx.recv().await {
610                        Some(r) => r,
611                        None => break,
612                    },
613                };
614
615                // Latest-position coalescing for queued motion events. A
616                // popped event that cannot be merged is stashed in
617                // `pending_req` (NOT dropped) and processed on the next
618                // iteration, preserving discrete-event ordering.
619                while attributed_mouse_is_motion(&req.event) {
620                    match attributed_rx.try_recv() {
621                        Ok(next_req)
622                            if attributed_mouse_coalescable(&req.event, &next_req.event) =>
623                        {
624                            req = next_req;
625                        }
626                        Ok(next_req) => {
627                            pending_req = Some(next_req);
628                            break;
629                        }
630                        Err(_) => break,
631                    }
632                }
633                let _ = SendAttributedInput::call(&*client_clone, req).await;
634            }
635        });
636    }
637
638    // Wait for initial output
639    for _ in 0..INITIAL_WAIT_ITERS {
640        pane.drain_pushes();
641        let parser = pane.shared_parser();
642        let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
643        if !parser.screen().contents_formatted().is_empty() {
644            break;
645        }
646        drop(parser);
647        std::thread::sleep(Duration::from_millis(INITIAL_WAIT_SLEEP_MS));
648    }
649
650    // Resize local parser to server-constrained geometry
651    {
652        let parser = pane.shared_parser();
653        let mut parser_lk = parser.lock().unwrap_or_else(|e| e.into_inner());
654        let (cur_rows, cur_cols) = parser_lk.screen().size();
655        if actual_cols != cur_cols || actual_rows != cur_rows {
656            parser_lk.screen_mut().set_size(actual_rows, actual_cols);
657        }
658        drop(parser_lk);
659    }
660
661    // Pass one stdout handle to init_terminal for the startup sequences
662    // and TerminalGuard teardown; keep a second handle for rendering.
663    //
664    // Redirect stderr to tracing only now that the terminal UI is about to
665    // take over, so macOS AppKit/NSPasteboard noise doesn't leak to the
666    // terminal display. Deferred until AFTER the Attach/Spawn/channel handshake
667    // so any connect/ABI error above reaches the real stderr and `main` can
668    // print it, instead of being swallowed into the (unsubscribed) tracing
669    // pipe. Best-effort: if it fails the session still works, just without the
670    // noise suppression.
671    #[cfg(unix)]
672    let _ = redirect_fd_to_tracing(libc::STDERR_FILENO, true);
673    let _guard = init_terminal(stdout())?;
674    let mut out = stdout();
675
676    let mut clipboard = Clipboard::new();
677    let sigint = install_sigint_handler()?;
678
679    // Channel for crossterm input events from a background thread
680    let (input_tx, input_rx) = crossbeam_channel::bounded::<Event>(INPUT_CHANNEL_CAPACITY);
681
682    // Spawn background crossterm input thread.
683    // Uses poll(INPUT_POLL_MS) so the thread can detect disconnection and exit
684    // promptly when run_session terminates.
685    std::thread::Builder::new()
686        .name("crossterm-input".into())
687        .spawn(move || {
688            loop {
689                match crossterm::event::poll(Duration::from_millis(INPUT_POLL_MS)) {
690                    Ok(true) => {
691                        if let Ok(crossterm_evt) = crossterm::event::read()
692                            && let Some(e) = convert_crossterm_event(crossterm_evt)
693                            && input_tx.send(e).is_err()
694                        {
695                            break;
696                        }
697                    }
698                    Ok(false) => continue,
699                    Err(_) => break,
700                }
701            }
702        })
703        .map_err(|e| io::Error::other(format!("spawn input thread: {e}")))?;
704
705    // Initial full-frame render
706    {
707        let parser = pane.shared_parser();
708        let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
709        let screen = parser.screen();
710        let (rows, cols) = screen.size();
711        render_frame(&mut out, screen, rows, cols, false)?;
712    }
713
714    let mut pending_input: Option<Event> = None;
715    loop {
716        let mut force_render = false;
717        let mut clear_display = false;
718
719        // Helper: synchronize parser geometry from server-driven resize signal.
720        // Returns true if geometry was actually updated.
721        let apply_pending_resize = |shared_parser: &Arc<Mutex<Parser>>| -> bool {
722            if resize_pending.swap(false, Ordering::Relaxed) {
723                let cols = server_cols.load(Ordering::Relaxed);
724                let rows = server_rows.load(Ordering::Relaxed);
725                if cols > 0 && rows > 0 {
726                    let mut parser_lk = shared_parser.lock().unwrap_or_else(|e| e.into_inner());
727                    let (cur_rows, cur_cols) = parser_lk.screen().size();
728                    if cur_cols != cols || cur_rows != rows {
729                        parser_lk.screen_mut().set_size(rows, cols);
730                        return true;
731                    }
732                }
733            }
734            false
735        };
736
737        // Site 1: Apply any pending resize that arrived before this iteration
738        let resized = apply_pending_resize(&pane.shared_parser());
739        force_render |= resized;
740        clear_display |= resized;
741
742        // Workspace rebind: server told us to switch channels.
743        // Checked BEFORE the blocking select so the signal is never stuck
744        // behind a crossbeam recv that has no pending data.
745        if let Some(target) = rebind_target
746            .lock()
747            .unwrap_or_else(|err| err.into_inner())
748            .take()
749        {
750            return Ok(Some(target));
751        }
752
753        // Retrieve next input event (either buffered from previous coalescing
754        // pass or blocking on the input/PTY-output channel)
755        let input_event = if let Some(evt) = pending_input.take() {
756            Some(evt)
757        } else {
758            crossbeam_channel::select! {
759                recv(input_rx) -> msg => {
760                    match msg {
761                        Ok(evt) => Some(evt),
762                        Err(_) => return Err(io::Error::other("input thread died")),
763                    }
764                }
765                recv(push_rx) -> msg => {
766                    match msg {
767                        Ok(data) => {
768                            // Site 2: Apply pending resize before parsing PTY bytes
769                            // (prevents DECAWM auto-scroll row duplication when
770                            // geometry changed between entering select and receiving
771                            // push_rx data)
772                            let resized = apply_pending_resize(&pane.shared_parser());
773                            force_render |= resized;
774                            clear_display |= resized;
775
776                            // PTY output — process directly into parser
777                            let parser = pane.shared_parser();
778                            let mut parser = parser.lock().unwrap_or_else(|e| e.into_inner());
779                            parser.process(&data);
780                            None
781                        }
782                        Err(_) => {
783                            // push channel disconnected → will be detected
784                            // by drain_pushes() below
785                            None
786                        }
787                    }
788                }
789                recv(crossbeam_channel::after(Duration::from_millis(100))) -> _ => {
790                    // Periodic wake-up: re-render even when idle, so the
791                    // display stays fresh after focus changes.
792                    force_render = true;
793                    None
794                }
795            }
796        };
797
798        // Drain any additional buffered PTY data
799        let has_new_data = pane.drain_pushes() || input_event.is_none();
800
801        // Drain clipboard
802        while let Ok(text) = clip_rx.try_recv() {
803            clipboard.set(&text);
804        }
805
806        // Handle SIGINT
807        if sigint.received() {
808            sigint.ack();
809            let _ = pane.write_bytes(&[0x03]);
810        }
811
812        // Handle the input event (if any)
813        if let Some(mut evt) = input_event {
814            // Coalesce rapid mouse motion (Moved / Drag) events currently in
815            // the channel buffer.  Only the latest position matters — discard
816            // intermediate positions.  Modifier changes and non-motion events
817            // break the coalescing loop so they are never lost or reordered.
818            if let Event::Mouse(ref mut mouse) = evt
819                && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
820            {
821                while let Ok(next_evt) = input_rx.try_recv() {
822                    match next_evt {
823                        Event::Mouse(ref next_mouse)
824                            if is_coalescable_mouse(
825                                &mouse.kind,
826                                &mouse.modifiers,
827                                &next_mouse.kind,
828                                &next_mouse.modifiers,
829                            ) =>
830                        {
831                            *mouse = *next_mouse;
832                        }
833                        other => {
834                            pending_input = Some(other);
835                            break;
836                        }
837                    }
838                }
839            }
840
841            // Send structured event to server for attributed input routing.
842            // Non-blocking: drops events if queue is full (acceptable for
843            // high-frequency mouse movements).
844            {
845                use term_session_muxio_service_definitions::SendAttributedInputRequest;
846                let _ = attributed_tx.try_send(SendAttributedInputRequest {
847                    channel: channel.to_string(),
848                    event: evt.clone(),
849                });
850            }
851
852            match evt {
853                Event::Key(ref key)
854                    if key.kind == KeyKind::Press || key.kind == KeyKind::Repeat =>
855                {
856                    let bytes = key_to_bytes(key, false);
857                    if !bytes.is_empty() {
858                        let _ = pane.write_bytes(&bytes);
859                    }
860                }
861                Event::Mouse(ref mouse) => {
862                    let mouse_active = {
863                        let parser = pane.shared_parser();
864                        let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
865                        parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
866                    };
867                    if mouse_active {
868                        let bytes = mouse_event_to_bytes(mouse, MouseProtocolEncoding::Sgr);
869                        if !bytes.is_empty() {
870                            let _ = pane.write_bytes(&bytes);
871                        }
872                    }
873                }
874                Event::Resize(w, h) => {
875                    let size = PtySize {
876                        rows: h,
877                        cols: w,
878                        pixel_width: 0,
879                        pixel_height: 0,
880                    };
881                    if let Err(err) = pane.resize(size) {
882                        tracing::warn!(error = %err, "resize request failed on PTY pane");
883                    }
884                    force_render = true;
885                    clear_display = true;
886                }
887                Event::Paste(text) => {
888                    let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
889                    wrapped.extend_from_slice(b"\x1b[200~");
890                    wrapped.extend_from_slice(text.as_bytes());
891                    wrapped.extend_from_slice(b"\x1b[201~");
892                    let _ = pane.write_bytes(&wrapped);
893                }
894                _ => {}
895            }
896        }
897
898        // Connection health — check after wakeup
899        if !client.is_connected() {
900            return Err(io::Error::other("connection to session server lost"));
901        }
902
903        // Full-frame explicit row-by-row render
904        if has_new_data || force_render {
905            let parser = pane.shared_parser();
906            let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
907            let screen = parser.screen();
908            let (rows, cols) = screen.size();
909            render_frame(&mut out, screen, rows, cols, clear_display)?;
910        }
911
912        // Exit on session exit
913        if pane.has_exited() {
914            return Ok(None);
915        }
916    }
917}
918
919#[derive(Default, PartialEq, Clone, Copy)]
920struct CellStyle {
921    fg: term_wm_vt100::Color,
922    bg: term_wm_vt100::Color,
923    bold: bool,
924    dim: bool,
925    italic: bool,
926    underline: bool,
927    inverse: bool,
928}
929
930impl CellStyle {
931    fn from_cell(cell: &term_wm_vt100::Cell) -> Self {
932        Self {
933            fg: cell.fgcolor(),
934            bg: cell.bgcolor(),
935            bold: cell.bold(),
936            dim: cell.dim(),
937            italic: cell.italic(),
938            underline: cell.underline(),
939            inverse: cell.inverse(),
940        }
941    }
942}
943
944fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
945    write!(out, "\x1b[0m")?;
946    if style.bold {
947        write!(out, "\x1b[1m")?;
948    }
949    if style.dim {
950        write!(out, "\x1b[2m")?;
951    }
952    if style.italic {
953        write!(out, "\x1b[3m")?;
954    }
955    if style.underline {
956        write!(out, "\x1b[4m")?;
957    }
958    if style.inverse {
959        write!(out, "\x1b[7m")?;
960    }
961    match style.fg {
962        term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
963        term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
964        _ => {}
965    }
966    match style.bg {
967        term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
968        term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
969        _ => {}
970    }
971    Ok(())
972}
973
974pub fn render_frame(
975    out: &mut dyn Write,
976    screen: &Screen,
977    rows: u16,
978    cols: u16,
979    clear_display: bool,
980) -> io::Result<()> {
981    let mut buf =
982        Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
983    let mut active_style = CellStyle::default();
984
985    // Synchronized Output begin, hide cursor, reset attributes
986    buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
987    if clear_display {
988        buf.extend_from_slice(b"\x1b[2J");
989    }
990    buf.extend_from_slice(b"\x1b[?7l");
991
992    for row in 0..rows {
993        write!(buf, "\x1b[{};1H", row + 1)?;
994
995        let mut col: u16 = 0;
996        while col < cols {
997            // Compute cell width first to handle wide chars (CJK, emoji) that
998            // span multiple columns — checking col + width >= cols catches the
999            // right-edge case even when a wide char at cols-2 jumps past cols-1.
1000            let cell_opt = screen.cell(row, col);
1001            let contents = cell_opt.map_or("", |c| c.contents());
1002            let width = if contents.is_empty() {
1003                1
1004            } else {
1005                unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
1006            };
1007
1008            // Margin sanitation: clear right margin before writing the cell that
1009            // touches or passes the right edge.  Placing \x1b[K here (while the
1010            // cursor is still at col) avoids cursor-inclusive erasure of the cell.
1011            if col + width >= cols {
1012                buf.extend_from_slice(b"\x1b[0m\x1b[K");
1013                active_style = CellStyle::default();
1014            }
1015
1016            let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
1017            if style != active_style {
1018                apply_sgr(&mut buf, &style)?;
1019                active_style = style;
1020            }
1021
1022            if contents.is_empty() {
1023                buf.push(b' ');
1024            } else {
1025                buf.extend_from_slice(contents.as_bytes());
1026            }
1027
1028            col += width;
1029        }
1030    }
1031
1032    buf.extend_from_slice(b"\x1b[?7h");
1033    buf.extend_from_slice(b"\x1b[0m");
1034    let (cur_row, cur_col) = screen.cursor_position();
1035    write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
1036    if screen.hide_cursor() {
1037        buf.extend_from_slice(b"\x1b[?25l");
1038    } else {
1039        buf.extend_from_slice(b"\x1b[?25h");
1040    }
1041    // Synchronized Output end — terminal now paints atomically
1042    buf.extend_from_slice(b"\x1b[?2026l");
1043
1044    out.write_all(&buf)?;
1045    out.flush()
1046}
1047
1048#[allow(clippy::unwrap_used)]
1049#[cfg(test)]
1050mod tests {
1051    use super::*;
1052    use std::sync::{Arc, Mutex};
1053
1054    use term_wm_events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
1055
1056    #[test]
1057    fn should_block_nesting_truth_table() {
1058        // Same gateway → block
1059        assert!(
1060            should_block_nesting(
1061                Some("term-wm/prod/alice/gateway"),
1062                "term-wm/prod/alice/gateway",
1063                false
1064            ),
1065            "same gateway, no override -> block"
1066        );
1067        // Different gateway → proceed
1068        assert!(
1069            !should_block_nesting(
1070                Some("term-wm/prod/alice/gateway"),
1071                "term-wm/dev/alice/gateway",
1072                false
1073            ),
1074            "different gateway -> proceed"
1075        );
1076        // Same gateway + allow_nested → proceed
1077        assert!(
1078            !should_block_nesting(
1079                Some("term-wm/prod/alice/gateway"),
1080                "term-wm/prod/alice/gateway",
1081                true
1082            ),
1083            "same gateway, allow -> proceed"
1084        );
1085        // No gateway set → proceed
1086        assert!(
1087            !should_block_nesting(None, "any-gateway", false),
1088            "no gateway set -> proceed"
1089        );
1090        // No gateway set + allow_nested → proceed
1091        assert!(
1092            !should_block_nesting(None, "any-gateway", true),
1093            "no gateway, allow -> proceed"
1094        );
1095    }
1096
1097    #[test]
1098    fn nested_session_fatal_error_brands_with_app_name() {
1099        let err = nested_session_fatal_error("my-app", Some("host/gateway"), "target/gateway");
1100        let msg = err.to_string();
1101        assert!(msg.contains("my-app"), "error must contain app name: {msg}");
1102        assert!(
1103            msg.contains("--allow-nested"),
1104            "error must recommend --allow-nested: {msg}"
1105        );
1106        assert!(
1107            msg.starts_with("FATAL:"),
1108            "error must start with FATAL: {msg}"
1109        );
1110    }
1111
1112    #[test]
1113    fn nested_session_fatal_error_names_both_endpoints() {
1114        let err = nested_session_fatal_error(
1115            "term-wm",
1116            Some("term-wm/alice/gateway"),
1117            "term-wm-dev/alice/gateway",
1118        );
1119        let msg = err.to_string();
1120        assert!(
1121            msg.contains("Active session gateway: term-wm/alice/gateway"),
1122            "must name the active endpoint: {msg}"
1123        );
1124        assert!(
1125            msg.contains("Requested gateway:       term-wm-dev/alice/gateway"),
1126            "must name the requested endpoint: {msg}"
1127        );
1128        // Unset markers render as an explicit placeholder, never silently.
1129        let unset = nested_session_fatal_error("term-wm", None, "x/gateway").to_string();
1130        assert!(
1131            unset.contains("Active session gateway: <unset>"),
1132            "unset marker must render as placeholder: {unset}"
1133        );
1134    }
1135
1136    #[test]
1137    fn is_nested_session_fatal_detects_fatal_errors() {
1138        let fatal = nested_session_fatal_error("term-wm", Some("h/g"), "t/g");
1139        assert!(is_nested_session_fatal(&fatal), "must detect fatal error");
1140
1141        let other = io::Error::other("some other error");
1142        assert!(
1143            !is_nested_session_fatal(&other),
1144            "must not false-positive on other errors"
1145        );
1146
1147        let empty = io::Error::other("");
1148        assert!(
1149            !is_nested_session_fatal(&empty),
1150            "must not false-positive on empty message"
1151        );
1152    }
1153
1154    struct TestWriter {
1155        buf: Arc<Mutex<Vec<u8>>>,
1156    }
1157
1158    impl TestWriter {
1159        fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
1160            let buf = Arc::new(Mutex::new(Vec::new()));
1161            (Self { buf: buf.clone() }, buf)
1162        }
1163    }
1164
1165    impl Write for TestWriter {
1166        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1167            self.buf.lock().unwrap().extend_from_slice(buf);
1168            Ok(buf.len())
1169        }
1170        fn flush(&mut self) -> io::Result<()> {
1171            Ok(())
1172        }
1173    }
1174
1175    /// Calls the real `init_terminal()` with a test writer and verifies
1176    /// the bracketed paste enable sequence `\x1b[?2004h` is written.
1177    /// Under `cargo test` stdin is a pipe, so `is_terminal()` returns false
1178    /// and the raw-mode OS call is skipped — only the ANSI output matters.
1179    #[test]
1180    fn init_terminal_writes_bracketed_paste_enable() {
1181        let (writer, buf) = TestWriter::new();
1182        let _guard = init_terminal(writer).expect("init_terminal");
1183        let bytes = buf.lock().unwrap();
1184        assert!(
1185            bytes
1186                .windows(b"\x1b[?2004h".len())
1187                .any(|w| w == b"\x1b[?2004h")
1188        );
1189    }
1190
1191    /// crossterm's Windows `EnableMouseCapture` writes no ANSI (it only calls
1192    /// `SetConsoleMode`), so `init_terminal` must emit the mouse-enable
1193    /// sequences itself — otherwise a host emulator (term-wm via ConPTY) never
1194    /// routes mouse input to the client and the mouse is dead inside a session.
1195    #[test]
1196    fn init_terminal_writes_mouse_enable_ansi() {
1197        let (writer, buf) = TestWriter::new();
1198        let _guard = init_terminal(writer).expect("init_terminal");
1199        let bytes = buf.lock().unwrap();
1200        assert!(
1201            bytes
1202                .windows(b"\x1b[?1000h".len())
1203                .any(|w| w == b"\x1b[?1000h"),
1204            "init_terminal must emit the mouse-enable ANSI so a host routes mouse input"
1205        );
1206    }
1207
1208    // ── client identity helpers ─────────────────────────────────────
1209    //
1210    // These mutate `SSH_CLIENT`/`SSH_CONNECTION`, which is process-global;
1211    // a static mutex serializes them against other tests (and each other).
1212
1213    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1214        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1215        LOCK.lock().unwrap_or_else(|e| e.into_inner())
1216    }
1217
1218    #[test]
1219    fn client_ssh_ip_from_ssh_client() {
1220        let _guard = env_lock();
1221        unsafe {
1222            std::env::set_var("SSH_CLIENT", "192.168.1.50 54321 22");
1223            std::env::remove_var("SSH_CONNECTION");
1224        }
1225        assert_eq!(client_ssh_ip().as_deref(), Some("192.168.1.50"));
1226        unsafe {
1227            std::env::remove_var("SSH_CLIENT");
1228        }
1229    }
1230
1231    #[test]
1232    fn client_ssh_ip_from_ssh_connection_fallback() {
1233        let _guard = env_lock();
1234        unsafe {
1235            std::env::remove_var("SSH_CLIENT");
1236            std::env::set_var("SSH_CONNECTION", "10.0.0.7 48000 10.0.0.1 22");
1237        }
1238        assert_eq!(client_ssh_ip().as_deref(), Some("10.0.0.7"));
1239        unsafe {
1240            std::env::remove_var("SSH_CONNECTION");
1241        }
1242    }
1243
1244    #[test]
1245    fn client_ssh_ip_ssh_client_wins_over_connection() {
1246        let _guard = env_lock();
1247        unsafe {
1248            std::env::set_var("SSH_CLIENT", "1.2.3.4 1000 22");
1249            std::env::set_var("SSH_CONNECTION", "9.9.9.9 2000 1.1.1.1 22");
1250        }
1251        assert_eq!(client_ssh_ip().as_deref(), Some("1.2.3.4"));
1252        unsafe {
1253            std::env::remove_var("SSH_CLIENT");
1254            std::env::remove_var("SSH_CONNECTION");
1255        }
1256    }
1257
1258    #[test]
1259    fn client_ssh_ip_none_when_local() {
1260        let _guard = env_lock();
1261        unsafe {
1262            std::env::remove_var("SSH_CLIENT");
1263            std::env::remove_var("SSH_CONNECTION");
1264        }
1265        assert_eq!(client_ssh_ip(), None);
1266    }
1267
1268    #[test]
1269    fn client_version_matches_package() {
1270        assert_eq!(client_version(), env!("CARGO_PKG_VERSION"));
1271    }
1272
1273    #[test]
1274    fn client_user_non_empty() {
1275        assert!(!client_user().is_empty(), "client user must resolve");
1276    }
1277
1278    #[test]
1279    #[cfg(windows)]
1280    fn client_user_prefers_username_env_when_set() {
1281        let _guard = env_lock();
1282        unsafe {
1283            std::env::set_var("USERNAME", "win-test-user");
1284        }
1285        assert_eq!(client_user(), "win-test-user");
1286        unsafe {
1287            std::env::remove_var("USERNAME");
1288        }
1289    }
1290
1291    #[test]
1292    #[cfg(windows)]
1293    fn client_user_falls_back_to_getusername_when_env_absent() {
1294        let _guard = env_lock();
1295        unsafe {
1296            std::env::remove_var("USERNAME");
1297        }
1298        // `USERNAME` is normally always set on Windows; with it removed, the
1299        // `GetUserNameW` fallback must still resolve the real account.
1300        assert!(
1301            !client_user().is_empty(),
1302            "GetUserNameW fallback must resolve a user"
1303        );
1304    }
1305
1306    /// Constructs a TerminalGuard with a test writer and verifies that
1307    /// dropping it writes the bracketed paste disable sequence `\x1b[?2004l`.
1308    #[test]
1309    fn terminal_guard_teardown_writes_bracketed_paste_disable() {
1310        let (writer, buf) = TestWriter::new();
1311        {
1312            let _guard = TerminalGuard {
1313                writer: Some(writer),
1314            };
1315        }
1316        let bytes = buf.lock().unwrap();
1317        assert!(
1318            bytes
1319                .windows(b"\x1b[?2004l".len())
1320                .any(|w| w == b"\x1b[?2004l")
1321        );
1322    }
1323
1324    /// Mirror of the init fix: teardown must emit the ANSI mouse-disable so a
1325    /// host emulator stops routing mouse input to the client.
1326    #[test]
1327    fn terminal_guard_teardown_writes_mouse_disable_ansi() {
1328        let (writer, buf) = TestWriter::new();
1329        {
1330            let _guard = TerminalGuard {
1331                writer: Some(writer),
1332            };
1333        }
1334        let bytes = buf.lock().unwrap();
1335        assert!(
1336            bytes
1337                .windows(b"\x1b[?1000l".len())
1338                .any(|w| w == b"\x1b[?1000l"),
1339            "TerminalGuard teardown must emit the mouse-disable ANSI"
1340        );
1341    }
1342
1343    /// Full lifecycle: init_terminal followed by TerminalGuard teardown
1344    /// writes both the enable and disable sequences.
1345    #[test]
1346    fn init_and_teardown_roundtrip_contains_both_sequences() {
1347        let (writer, buf) = TestWriter::new();
1348        let guard = init_terminal(writer).expect("init_terminal");
1349        drop(guard);
1350        let bytes = buf.lock().unwrap();
1351        assert!(
1352            bytes
1353                .windows(b"\x1b[?2004h".len())
1354                .any(|w| w == b"\x1b[?2004h")
1355        );
1356        assert!(
1357            bytes
1358                .windows(b"\x1b[?2004l".len())
1359                .any(|w| w == b"\x1b[?2004l")
1360        );
1361    }
1362
1363    /// Proves that reusing a parser via set_size + RIS + process yields
1364    /// identical screen state to a freshly allocated parser.
1365    #[test]
1366    fn test_prev_parser_resize_sync_matches_fresh_parser() {
1367        let mut prev_parser = term_wm_vt100::Parser::new(24, 80, 0);
1368        prev_parser.process(b"initial screen content");
1369
1370        // Simulate terminal window resize to 40x120
1371        let (new_rows, new_cols) = (40, 120);
1372        let new_formatted_content = {
1373            let mut p = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1374            p.process(b"resized screen content");
1375            p.screen().contents_formatted().to_vec()
1376        };
1377
1378        // Re-use prev_parser using dimension sync + RIS reset
1379        prev_parser.screen_mut().set_size(new_rows, new_cols);
1380        prev_parser.process(b"\x1bc");
1381        prev_parser.process(&new_formatted_content);
1382
1383        // Verify against a freshly created parser
1384        let mut fresh_parser = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1385        fresh_parser.process(&new_formatted_content);
1386
1387        assert_eq!(
1388            prev_parser.screen().contents_formatted(),
1389            fresh_parser.screen().contents_formatted(),
1390            "Reused parser state after set_size + RIS must match fresh parser"
1391        );
1392    }
1393
1394    #[test]
1395    fn render_frame_outputs_correct_cup_and_sgr() {
1396        let mut parser = term_wm_vt100::Parser::new(4, 8, 0);
1397        parser.process(b"\x1b[31mhello\x1b[0m");
1398        let screen = parser.screen();
1399        let mut buf: Vec<u8> = Vec::new();
1400        let (rows, cols) = screen.size();
1401        render_frame(&mut buf, screen, rows, cols, false).unwrap();
1402        let output = String::from_utf8_lossy(&buf);
1403        // Should contain CUP to each row (4 rows)
1404        assert!(output.contains("\x1b[1;1H"));
1405        assert!(output.contains("\x1b[2;1H"));
1406        assert!(output.contains("\x1b[3;1H"));
1407        assert!(output.contains("\x1b[4;1H"));
1408        // Should contain "hello"
1409        assert!(output.contains("hello"));
1410        // Should contain red foreground SGR
1411        assert!(
1412            output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
1413            "Expected red foreground SGR in output: {output:?}"
1414        );
1415        // Should not contain raw ESC characters without following sequences
1416        assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
1417    }
1418
1419    // ── is_coalescable_mouse tests ────────────────────────────────────────
1420
1421    #[test]
1422    fn coalesce_moved_with_moved() {
1423        assert!(is_coalescable_mouse(
1424            &MouseEventKind::Moved,
1425            &KeyModifiers::NONE,
1426            &MouseEventKind::Moved,
1427            &KeyModifiers::NONE,
1428        ));
1429    }
1430
1431    #[test]
1432    fn coalesce_drag_same_button() {
1433        assert!(is_coalescable_mouse(
1434            &MouseEventKind::Drag(MouseButton::Left),
1435            &KeyModifiers::NONE,
1436            &MouseEventKind::Drag(MouseButton::Left),
1437            &KeyModifiers::NONE,
1438        ));
1439        assert!(is_coalescable_mouse(
1440            &MouseEventKind::Drag(MouseButton::Right),
1441            &KeyModifiers {
1442                shift: true,
1443                ..KeyModifiers::NONE
1444            },
1445            &MouseEventKind::Drag(MouseButton::Right),
1446            &KeyModifiers {
1447                shift: true,
1448                ..KeyModifiers::NONE
1449            },
1450        ));
1451    }
1452
1453    #[test]
1454    fn reject_drag_different_button() {
1455        assert!(!is_coalescable_mouse(
1456            &MouseEventKind::Drag(MouseButton::Left),
1457            &KeyModifiers::NONE,
1458            &MouseEventKind::Drag(MouseButton::Right),
1459            &KeyModifiers::NONE,
1460        ));
1461    }
1462
1463    #[test]
1464    fn reject_moved_vs_drag() {
1465        assert!(!is_coalescable_mouse(
1466            &MouseEventKind::Moved,
1467            &KeyModifiers::NONE,
1468            &MouseEventKind::Drag(MouseButton::Left),
1469            &KeyModifiers::NONE,
1470        ));
1471    }
1472
1473    #[test]
1474    fn reject_different_modifiers() {
1475        assert!(!is_coalescable_mouse(
1476            &MouseEventKind::Moved,
1477            &KeyModifiers::NONE,
1478            &MouseEventKind::Moved,
1479            &KeyModifiers {
1480                shift: true,
1481                ..KeyModifiers::NONE
1482            },
1483        ));
1484        assert!(!is_coalescable_mouse(
1485            &MouseEventKind::Drag(MouseButton::Left),
1486            &KeyModifiers {
1487                control: true,
1488                ..KeyModifiers::NONE
1489            },
1490            &MouseEventKind::Drag(MouseButton::Left),
1491            &KeyModifiers::NONE,
1492        ));
1493    }
1494
1495    #[test]
1496    fn reject_discrete_events() {
1497        assert!(!is_coalescable_mouse(
1498            &MouseEventKind::Press(MouseButton::Left),
1499            &KeyModifiers::NONE,
1500            &MouseEventKind::Press(MouseButton::Left),
1501            &KeyModifiers::NONE,
1502        ));
1503        assert!(!is_coalescable_mouse(
1504            &MouseEventKind::Release(MouseButton::Left),
1505            &KeyModifiers::NONE,
1506            &MouseEventKind::Moved,
1507            &KeyModifiers::NONE,
1508        ));
1509        assert!(!is_coalescable_mouse(
1510            &MouseEventKind::Moved,
1511            &KeyModifiers::NONE,
1512            &MouseEventKind::ScrollDown,
1513            &KeyModifiers::NONE,
1514        ));
1515        assert!(!is_coalescable_mouse(
1516            &MouseEventKind::ScrollUp,
1517            &KeyModifiers::NONE,
1518            &MouseEventKind::ScrollUp,
1519            &KeyModifiers::NONE,
1520        ));
1521    }
1522
1523    // ── Coalescing loop integration tests ─────────────────────────────────
1524
1525    /// Helper: run the coalescing logic from the main loop against a real
1526    /// bounded channel, returning the final event (or None if filtered away).
1527    fn coalesce_through(
1528        events: &[Event],
1529        kind: MouseEventKind,
1530        modifiers: KeyModifiers,
1531    ) -> Option<Event> {
1532        let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
1533        for e in events.iter().cloned() {
1534            tx.send(e).ok();
1535        }
1536        drop(tx);
1537
1538        let mut result = Event::Mouse(MouseEvent {
1539            kind,
1540            modifiers,
1541            column: 0,
1542            row: 0,
1543        });
1544
1545        if let Event::Mouse(ref mut mouse) = result
1546            && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
1547        {
1548            while let Ok(next) = rx.try_recv() {
1549                match next {
1550                    Event::Mouse(ref next_mouse)
1551                        if is_coalescable_mouse(
1552                            &mouse.kind,
1553                            &mouse.modifiers,
1554                            &next_mouse.kind,
1555                            &next_mouse.modifiers,
1556                        ) =>
1557                    {
1558                        *mouse = *next_mouse;
1559                    }
1560                    _other => return Some(result),
1561                }
1562            }
1563        }
1564
1565        Some(result)
1566    }
1567
1568    #[test]
1569    fn coalesce_keeps_latest_moved_position() {
1570        let events = vec![
1571            Event::Mouse(MouseEvent {
1572                kind: MouseEventKind::Moved,
1573                modifiers: KeyModifiers::NONE,
1574                column: 5,
1575                row: 5,
1576            }),
1577            Event::Mouse(MouseEvent {
1578                kind: MouseEventKind::Moved,
1579                modifiers: KeyModifiers::NONE,
1580                column: 10,
1581                row: 10,
1582            }),
1583            Event::Mouse(MouseEvent {
1584                kind: MouseEventKind::Moved,
1585                modifiers: KeyModifiers::NONE,
1586                column: 15,
1587                row: 15,
1588            }),
1589        ];
1590        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1591        let Event::Mouse(m) = result.unwrap() else {
1592            panic!("expected mouse")
1593        };
1594        assert_eq!((m.column, m.row), (15, 15));
1595    }
1596
1597    #[test]
1598    fn coalesce_keeps_latest_drag_position() {
1599        let events = vec![
1600            Event::Mouse(MouseEvent {
1601                kind: MouseEventKind::Drag(MouseButton::Left),
1602                modifiers: KeyModifiers::NONE,
1603                column: 1,
1604                row: 1,
1605            }),
1606            Event::Mouse(MouseEvent {
1607                kind: MouseEventKind::Drag(MouseButton::Left),
1608                modifiers: KeyModifiers::NONE,
1609                column: 2,
1610                row: 2,
1611            }),
1612        ];
1613        let result = coalesce_through(
1614            &events,
1615            MouseEventKind::Drag(MouseButton::Left),
1616            KeyModifiers::NONE,
1617        );
1618        let Event::Mouse(m) = result.unwrap() else {
1619            panic!("expected mouse")
1620        };
1621        assert_eq!((m.column, m.row), (2, 2));
1622    }
1623
1624    #[test]
1625    fn coalesce_stops_at_modifier_change() {
1626        let events = vec![Event::Mouse(MouseEvent {
1627            kind: MouseEventKind::Moved,
1628            modifiers: KeyModifiers {
1629                shift: true,
1630                ..KeyModifiers::NONE
1631            },
1632            column: 99,
1633            row: 99,
1634        })];
1635        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1636        let Event::Mouse(m) = result.unwrap() else {
1637            panic!("expected mouse")
1638        };
1639        // The first event (modifier change) should NOT be consumed — we
1640        // still hold the original event at (0,0) with NONE modifiers.
1641        assert_eq!((m.column, m.row), (0, 0));
1642    }
1643
1644    #[test]
1645    fn coalesce_stops_at_non_mouse_event() {
1646        let key = Event::Key(KeyEvent {
1647            code: KeyCode::Char('q'),
1648            kind: KeyKind::Press,
1649            modifiers: KeyModifiers::NONE,
1650        });
1651        let events = vec![key.clone()];
1652        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1653        let Event::Mouse(m) = result.unwrap() else {
1654            panic!("expected mouse")
1655        };
1656        // Should retain original event, not consuming the key
1657        assert_eq!((m.column, m.row), (0, 0));
1658    }
1659
1660    #[test]
1661    fn coalesce_stops_at_discrete_mouse_event() {
1662        let events = vec![Event::Mouse(MouseEvent {
1663            kind: MouseEventKind::Press(MouseButton::Left),
1664            modifiers: KeyModifiers::NONE,
1665            column: 10,
1666            row: 10,
1667        })];
1668        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1669        let Event::Mouse(m) = result.unwrap() else {
1670            panic!("expected mouse")
1671        };
1672        // Should NOT consume the Press event
1673        assert_eq!((m.column, m.row), (0, 0));
1674    }
1675}
1676
1677// ── Snapshot tests for render_frame byte output ─────────────────────────
1678// Uses a push_rx mock (crossbeam channel) + RemotePane(client: None) for
1679// deterministic, non-flaky byte-stream assertions.
1680#[allow(clippy::unwrap_used)]
1681#[cfg(test)]
1682#[allow(clippy::type_complexity)]
1683mod snapshot_tests {
1684    use super::*;
1685
1686    /// Render a screen from deterministic PTY bytes, capturing the raw
1687    /// ANSI output.
1688    fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
1689        let rt = tokio::runtime::Builder::new_current_thread()
1690            .build()
1691            .expect("tokio rt");
1692        let (push_tx, push_rx) = crossbeam_channel::bounded(16);
1693        let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
1694        let mut pane = RemotePane::new(
1695            0,
1696            None,
1697            rt.handle().clone(),
1698            cols,
1699            rows,
1700            push_rx,
1701            input_writer,
1702        );
1703        drop(rt); // rt must outlive the channels but not RemotePane
1704
1705        push_tx.send(pty_bytes.to_vec()).ok();
1706        pane.drain_pushes();
1707
1708        let parser = pane.shared_parser();
1709        let parser = parser.lock().unwrap();
1710        let screen = parser.screen();
1711        let (rows, cols) = screen.size();
1712        let mut out = Vec::new();
1713        render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
1714        out
1715    }
1716
1717    /// Escape ANSI and control bytes for readable snapshot diffs.
1718    fn escape_ansi(bytes: &[u8]) -> String {
1719        let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
1720        for &b in bytes {
1721            match b {
1722                b'\x1b' => out.extend_from_slice(b"\\x1b"),
1723                b'\n' => out.extend_from_slice(b"\\n"),
1724                b'\r' => out.extend_from_slice(b"\\r"),
1725                b'\t' => out.extend_from_slice(b"\\t"),
1726                0x20..=0x7e => out.push(b),
1727                _ => {
1728                    out.push(b'\\');
1729                    out.push(b'x');
1730                    out.extend_from_slice(&hex_byte(b));
1731                }
1732            }
1733        }
1734        // SAFETY: all bytes are valid ASCII (0x20-0x7e or escaped sequences)
1735        unsafe { String::from_utf8_unchecked(out) }
1736    }
1737
1738    fn hex_byte(b: u8) -> [u8; 2] {
1739        #[inline]
1740        fn hex_nibble(n: u8) -> u8 {
1741            let digit = n & 0x0f;
1742            if digit < 10 {
1743                b'0' + digit
1744            } else {
1745                b'a' + digit - 10
1746            }
1747        }
1748        [hex_nibble(b >> 4), hex_nibble(b)]
1749    }
1750
1751    // ── Tests ────────────────────────────────────────────────────────
1752
1753    #[test]
1754    fn snapshot_empty_grid() {
1755        let out = render_and_capture(b"", 4, 8, false);
1756        insta::assert_snapshot!("empty_grid", escape_ansi(&out));
1757    }
1758
1759    #[test]
1760    fn snapshot_basic_text() {
1761        let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
1762        insta::assert_snapshot!("basic_text", escape_ansi(&out));
1763    }
1764
1765    #[test]
1766    fn snapshot_colored_text() {
1767        let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
1768        insta::assert_snapshot!("colored_text", escape_ansi(&out));
1769    }
1770
1771    #[test]
1772    fn snapshot_normal_char_at_margin() {
1773        // Fill a 4-wide grid so the last column contains 'D' — triggers
1774        // margin sanitation before the final cell in the row.
1775        let out = render_and_capture(b"ABCD", 1, 4, false);
1776        insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
1777    }
1778
1779    #[test]
1780    fn snapshot_clear_display() {
1781        let out = render_and_capture(b"", 4, 8, true);
1782        insta::assert_snapshot!("clear_display", escape_ansi(&out));
1783    }
1784
1785    #[test]
1786    fn snapshot_hidden_cursor() {
1787        let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
1788        insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
1789    }
1790
1791    #[test]
1792    fn snapshot_color_across_margin() {
1793        // Red background on a block char right at the last column.
1794        // Verify \x1b[0m resets the color before \x1b[K clears the margin.
1795        let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
1796        insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
1797    }
1798
1799    #[test]
1800    fn snapshot_multi_row_fill() {
1801        // Fill all 4×3 cells with unique chars to verify row-by-row CUP +
1802        // margin sanitation on every row.
1803        let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
1804        insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
1805    }
1806
1807    #[test]
1808    fn snapshot_wide_char_margin() {
1809        // Use 3-wide grid with CJK at col 1 (width 2, fills cols 1-2).
1810        // Margin check: 1 + 2 = 3 >= 3 → \x1b[K fires before the char.
1811        let out = render_and_capture(
1812            b"B\xe3\x81\x82", // HIRAGANA A (U+3042, width 2 in unicode-width)
1813            1,
1814            3,
1815            false,
1816        );
1817        insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
1818    }
1819
1820    #[test]
1821    fn snapshot_wide_char_middle() {
1822        // Wide char at col 1 in a 5-wide grid (cols 1-2).  Does NOT
1823        // trigger margin sanitation (1 + 2 = 3 < 5) — verifies wide
1824        // chars render correctly in the middle of a row.
1825        let out = render_and_capture(
1826            b"A\xe3\x81\x82\xe3\x81\x83", // CJK chars at col 1 and col 3
1827            1,
1828            5,
1829            false,
1830        );
1831        insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1832    }
1833}