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