Skip to main content

term_session_client/
lib.rs

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