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,
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    // Redirect stderr to tracing so macOS AppKit/NSPasteboard noise doesn't
315    // leak to the terminal display.  Best-effort: if it fails (non-Unix, etc.)
316    // the session still works, just without the noise suppression.
317    #[cfg(unix)]
318    let _ = redirect_fd_to_tracing(libc::STDERR_FILENO, true);
319
320    let rt =
321        tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
322
323    // Connect via muxio IPC
324    let client: Arc<RpcIpcClient> = rt
325        .block_on(RpcIpcClient::new(socket_path))
326        .map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, format!("{e:?}")))?;
327
328    // ABI/transport fault interception: a decode/parse fault during the
329    // handshake (e.g. an upgraded client against a legacy daemon on the same
330    // socket) must produce a clear diagnostic, never a panic or silent drop.
331    let abi_fault = |e: &dyn std::fmt::Display| -> io::Error {
332        io::Error::other(format!(
333            "FATAL: Protocol ABI mismatch. A legacy daemon may be occupying the IPC socket. Manually terminate the daemon process before continuing. (cause: {e})"
334        ))
335    };
336
337    // Atomic registers for server-initiated geometry changes (OnPtyResized).
338    // Initialised before Attach/Spawn so the handler is registered before
339    // the server can send any notifications — prevents RpcMethodNotFound.
340    let server_cols = Arc::new(AtomicU16::new(0));
341    let server_rows = Arc::new(AtomicU16::new(0));
342    let resize_pending = Arc::new(AtomicBool::new(false));
343
344    {
345        let cols_ref = Arc::clone(&server_cols);
346        let rows_ref = Arc::clone(&server_rows);
347        let pending_ref = Arc::clone(&resize_pending);
348        rt.block_on(client.get_endpoint().register_prebuffered(
349            OnPtyResized::METHOD_ID,
350            move |payload, _ctx| {
351                let cols_ref = Arc::clone(&cols_ref);
352                let rows_ref = Arc::clone(&rows_ref);
353                let pending_ref = Arc::clone(&pending_ref);
354                async move {
355                    let (cols, rows) = OnPtyResized::decode_request(&payload)
356                        .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
357                    cols_ref.store(cols, Ordering::Relaxed);
358                    rows_ref.store(rows, Ordering::Relaxed);
359                    pending_ref.store(true, Ordering::Relaxed);
360                    OnPtyResized::encode_response(())
361                        .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
362                }
363            },
364        ))
365        .map_err(|e| io::Error::other(format!("register OnPtyResized: {e:?}")))?;
366    }
367
368    // Channels for raw PTY output bytes and clipboard text from the subscription stream.
369    // Using crossbeam so the main loop can block on both input and PTY output.
370    // Bounded to cap head-of-line queuing under burst load.
371    let (push_tx, push_rx) = crossbeam_channel::bounded::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
372    let (clip_tx, clip_rx) = crossbeam_channel::bounded::<String>(CLIPBOARD_CHANNEL_CAPACITY);
373
374    // Terminal geometry comes from the real terminal (no cols/rows are threaded
375    // through the API). The vt100 parser computes `rows - 1` at construction, so
376    // clamp a degenerate 0x0 report up to a non-zero grid rather than panicking.
377    // On Unix with redirected stdio (e.g. `>/dev/null` under CI or tests) the
378    // TIOCGWINSZ ioctl fails and `COLUMNS`/`LINES` are unset, so `size()` errors
379    // — fall back to a seed rather than aborting before Attach/Spawn.
380    let (term_cols, term_rows) = match crossterm::terminal::size() {
381        Ok((c, r)) => (c.max(MIN_TERM_COLS), r.max(MIN_TERM_ROWS)),
382        Err(_) => (FALLBACK_TERM_COLS, FALLBACK_TERM_ROWS),
383    };
384    let hostname = hostname::get()
385        .map(|h| h.to_string_lossy().into_owned())
386        .unwrap_or_else(|_| "unknown".to_string());
387
388    let (actual_cols, actual_rows) = rt.block_on(async {
389        // 1) Attach: bind this connection to the channel (server-assigned
390        // conn_id); report our OS PID so `list` can show which client is which.
391        let conn_id = Attach::call(
392            &*client,
393            AttachRequest {
394                channel: channel.to_string(),
395                hostname,
396                pid: std::process::id() as u64,
397                user: client_user(),
398                version: client_version(),
399                ssh_ip: client_ssh_ip(),
400            },
401        )
402        .await
403        .map_err(|e| abi_fault(&e))?;
404        // 2) Spawn: join/respawn the session (cmd travels via Spawn).
405        let cmd = if cmd.is_empty() {
406            None
407        } else {
408            Some(cmd.to_vec())
409        };
410        let (_session_id, actual_cols, actual_rows) =
411            Spawn::call(&*client, (cmd, term_cols, term_rows))
412                .await
413                .map_err(|e| abi_fault(&e))?;
414        let _ = conn_id;
415        Ok::<(u16, u16), io::Error>((actual_cols, actual_rows))
416    })?;
417
418    // Open streaming channels for output subscription and input
419    let writer = rt.block_on(async {
420        // Subscribe to PTY output via the mpsc adapter.
421        // `reader` yields response chunks (raw PTY output bytes).
422        let (_, mut reader) = client
423            .open_channel(SUBSCRIBE_OUTPUT_METHOD_ID, 0)
424            .await
425            .map_err(|e| io::Error::other(format!("subscribe: {e:?}")))?;
426
427        // Forward raw PTY output chunks to push_tx.  Each chunk from the
428        // muxio stream is a complete message — no custom framing needed.
429        // Intercept OSC 52 clipboard sequences before the parser consumes them.
430        rt.spawn(async move {
431            let mut osc52 = Osc52Extractor::new();
432            let mut prev_tail: [u8; PREV_TAIL_LEN] = [0; PREV_TAIL_LEN];
433
434            while let Some(chunk) = reader.recv().await {
435                if let Ok(mut data) = chunk {
436                    if let Some(text) = osc52.push(&data, &prev_tail) {
437                        let _ = clip_tx.try_send(text);
438                    }
439
440                    let n = data.len();
441                    if n >= PREV_TAIL_LEN {
442                        prev_tail.copy_from_slice(&data[n - PREV_TAIL_LEN..n]);
443                    } else if n > 0 {
444                        prev_tail.rotate_left(n);
445                        prev_tail[PREV_TAIL_LEN - n..].copy_from_slice(&data[..n]);
446                    }
447
448                    // Non-blocking push; if saturated, sleep 1ms to allow
449                    // the main loop to drain the channel without CPU spinning.
450                    while let Err(crossbeam_channel::TrySendError::Full(pending)) =
451                        push_tx.try_send(data)
452                    {
453                        data = pending;
454                        tokio::time::sleep(Duration::from_millis(BACKPRESSURE_SLEEP_MS)).await;
455                    }
456                } else {
457                    break;
458                }
459            }
460            // Flush any buffered OSC 52 payload at EOF (Windows ConPTY
461            // consumes the BEL/ST terminator).
462            if let Some(text) = osc52.finish() {
463                let _ = clip_tx.try_send(text);
464            }
465        });
466
467        // Open streaming channel for PTY input.
468        // `writer` accepts keystroke bytes.
469        let (writer, _) = client
470            .open_channel(STREAM_INPUT_METHOD_ID, 0)
471            .await
472            .map_err(|e| io::Error::other(format!("stream input: {e:?}")))?;
473
474        Ok::<_, io::Error>(writer)
475    })?;
476
477    let input_writer = Box::new(move |data: &[u8]| -> io::Result<()> {
478        writer
479            .send(data.to_vec())
480            .map_err(|e| io::Error::other(e.to_string()))?;
481        Ok(())
482    });
483
484    let mut pane = RemotePane::new(
485        1u64,
486        Some(client.clone()),
487        rt.handle().clone(),
488        term_cols,
489        term_rows,
490        push_rx.clone(),
491        input_writer,
492    );
493
494    // Wait for initial output
495    for _ in 0..INITIAL_WAIT_ITERS {
496        pane.drain_pushes();
497        let parser = pane.shared_parser();
498        let parser = parser.lock().unwrap();
499        if !parser.screen().contents_formatted().is_empty() {
500            break;
501        }
502        drop(parser);
503        std::thread::sleep(Duration::from_millis(INITIAL_WAIT_SLEEP_MS));
504    }
505
506    // Resize local parser to server-constrained geometry
507    {
508        let parser = pane.shared_parser();
509        let mut parser_lk = parser.lock().unwrap();
510        let (cur_rows, cur_cols) = parser_lk.screen().size();
511        if actual_cols != cur_cols || actual_rows != cur_rows {
512            parser_lk.screen_mut().set_size(actual_rows, actual_cols);
513        }
514        drop(parser_lk);
515    }
516
517    // Pass one stdout handle to init_terminal for the startup sequences
518    // and TerminalGuard teardown; keep a second handle for rendering.
519    let _guard = init_terminal(stdout())?;
520    let mut out = stdout();
521
522    let mut clipboard = Clipboard::new();
523    let sigint = install_sigint_handler()?;
524
525    // Channel for crossterm input events from a background thread
526    let (input_tx, input_rx) = crossbeam_channel::bounded::<Event>(INPUT_CHANNEL_CAPACITY);
527
528    // Spawn background crossterm input thread.
529    // Uses poll(INPUT_POLL_MS) so the thread can detect disconnection and exit
530    // promptly when run_session terminates.
531    std::thread::Builder::new()
532        .name("crossterm-input".into())
533        .spawn(move || {
534            loop {
535                match crossterm::event::poll(Duration::from_millis(INPUT_POLL_MS)) {
536                    Ok(true) => {
537                        if let Ok(crossterm_evt) = crossterm::event::read()
538                            && let Some(e) = convert_crossterm_event(crossterm_evt)
539                            && input_tx.send(e).is_err()
540                        {
541                            break;
542                        }
543                    }
544                    Ok(false) => continue,
545                    Err(_) => break,
546                }
547            }
548        })
549        .map_err(|e| io::Error::other(format!("spawn input thread: {e}")))?;
550
551    // Initial full-frame render
552    {
553        let parser = pane.shared_parser();
554        let parser = parser.lock().unwrap();
555        let screen = parser.screen();
556        let (rows, cols) = screen.size();
557        render_frame(&mut out, screen, rows, cols, false)?;
558    }
559
560    let mut pending_input: Option<Event> = None;
561    loop {
562        let mut force_render = false;
563        let mut clear_display = false;
564
565        // Helper: synchronize parser geometry from server-driven resize signal.
566        // Returns true if geometry was actually updated.
567        let apply_pending_resize = |shared_parser: &Arc<Mutex<Parser>>| -> bool {
568            if resize_pending.swap(false, Ordering::Relaxed) {
569                let cols = server_cols.load(Ordering::Relaxed);
570                let rows = server_rows.load(Ordering::Relaxed);
571                if cols > 0 && rows > 0 {
572                    let mut parser_lk = shared_parser.lock().unwrap();
573                    let (cur_rows, cur_cols) = parser_lk.screen().size();
574                    if cur_cols != cols || cur_rows != rows {
575                        parser_lk.screen_mut().set_size(rows, cols);
576                        return true;
577                    }
578                }
579            }
580            false
581        };
582
583        // Site 1: Apply any pending resize that arrived before this iteration
584        let resized = apply_pending_resize(&pane.shared_parser());
585        force_render |= resized;
586        clear_display |= resized;
587
588        // Retrieve next input event (either buffered from previous coalescing
589        // pass or blocking on the input/PTY-output channel)
590        let input_event = if let Some(evt) = pending_input.take() {
591            Some(evt)
592        } else {
593            crossbeam_channel::select! {
594                recv(input_rx) -> msg => {
595                    match msg {
596                        Ok(evt) => Some(evt),
597                        Err(_) => return Err(io::Error::other("input thread died")),
598                    }
599                }
600                recv(push_rx) -> msg => {
601                    match msg {
602                        Ok(data) => {
603                            // Site 2: Apply pending resize before parsing PTY bytes
604                            // (prevents DECAWM auto-scroll row duplication when
605                            // geometry changed between entering select and receiving
606                            // push_rx data)
607                            let resized = apply_pending_resize(&pane.shared_parser());
608                            force_render |= resized;
609                            clear_display |= resized;
610
611                            // PTY output — process directly into parser
612                            let parser = pane.shared_parser();
613                            let mut parser = parser.lock().unwrap();
614                            parser.process(&data);
615                            None
616                        }
617                        Err(_) => {
618                            // push channel disconnected → will be detected
619                            // by drain_pushes() below
620                            None
621                        }
622                    }
623                }
624            }
625        };
626
627        // Drain any additional buffered PTY data
628        let has_new_data = pane.drain_pushes() || input_event.is_none();
629
630        // Drain clipboard
631        while let Ok(text) = clip_rx.try_recv() {
632            clipboard.set(&text);
633        }
634
635        // Handle SIGINT
636        if sigint.received() {
637            sigint.ack();
638            let _ = pane.write_bytes(&[0x03]);
639        }
640
641        // Handle the input event (if any)
642        if let Some(mut evt) = input_event {
643            // Coalesce rapid mouse motion (Moved / Drag) events currently in
644            // the channel buffer.  Only the latest position matters — discard
645            // intermediate positions.  Modifier changes and non-motion events
646            // break the coalescing loop so they are never lost or reordered.
647            if let Event::Mouse(ref mut mouse) = evt
648                && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
649            {
650                while let Ok(next_evt) = input_rx.try_recv() {
651                    match next_evt {
652                        Event::Mouse(ref next_mouse)
653                            if is_coalescable_mouse(
654                                &mouse.kind,
655                                &mouse.modifiers,
656                                &next_mouse.kind,
657                                &next_mouse.modifiers,
658                            ) =>
659                        {
660                            *mouse = *next_mouse;
661                        }
662                        other => {
663                            pending_input = Some(other);
664                            break;
665                        }
666                    }
667                }
668            }
669
670            match evt {
671                Event::Key(ref key)
672                    if key.kind == KeyKind::Press || key.kind == KeyKind::Repeat =>
673                {
674                    let bytes = key_to_bytes(key, false);
675                    if !bytes.is_empty() {
676                        let _ = pane.write_bytes(&bytes);
677                    }
678                }
679                Event::Mouse(ref mouse) => {
680                    let mouse_active = {
681                        let parser = pane.shared_parser();
682                        let parser = parser.lock().unwrap();
683                        parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
684                    };
685                    if mouse_active {
686                        let bytes = mouse_event_to_bytes(mouse, MouseProtocolEncoding::Sgr);
687                        if !bytes.is_empty() {
688                            let _ = pane.write_bytes(&bytes);
689                        }
690                    }
691                }
692                Event::Resize(w, h) => {
693                    let size = PtySize {
694                        rows: h,
695                        cols: w,
696                        pixel_width: 0,
697                        pixel_height: 0,
698                    };
699                    if let Err(err) = pane.resize(size) {
700                        tracing::warn!(error = %err, "resize request failed on PTY pane");
701                    }
702                    force_render = true;
703                    clear_display = true;
704                }
705                Event::Paste(text) => {
706                    let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
707                    wrapped.extend_from_slice(b"\x1b[200~");
708                    wrapped.extend_from_slice(text.as_bytes());
709                    wrapped.extend_from_slice(b"\x1b[201~");
710                    let _ = pane.write_bytes(&wrapped);
711                }
712                _ => {}
713            }
714        }
715
716        // Connection health — check after wakeup
717        if !client.is_connected() {
718            return Err(io::Error::other("connection to session server lost"));
719        }
720
721        // Full-frame explicit row-by-row render
722        if has_new_data || force_render {
723            let parser = pane.shared_parser();
724            let parser = parser.lock().unwrap();
725            let screen = parser.screen();
726            let (rows, cols) = screen.size();
727            render_frame(&mut out, screen, rows, cols, clear_display)?;
728        }
729
730        // Exit on session exit
731        if pane.has_exited() {
732            return Ok(());
733        }
734    }
735}
736
737#[derive(Default, PartialEq, Clone, Copy)]
738struct CellStyle {
739    fg: vt100::Color,
740    bg: vt100::Color,
741    bold: bool,
742    dim: bool,
743    italic: bool,
744    underline: bool,
745    inverse: bool,
746}
747
748impl CellStyle {
749    fn from_cell(cell: &vt100::Cell) -> Self {
750        Self {
751            fg: cell.fgcolor(),
752            bg: cell.bgcolor(),
753            bold: cell.bold(),
754            dim: cell.dim(),
755            italic: cell.italic(),
756            underline: cell.underline(),
757            inverse: cell.inverse(),
758        }
759    }
760}
761
762fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
763    write!(out, "\x1b[0m")?;
764    if style.bold {
765        write!(out, "\x1b[1m")?;
766    }
767    if style.dim {
768        write!(out, "\x1b[2m")?;
769    }
770    if style.italic {
771        write!(out, "\x1b[3m")?;
772    }
773    if style.underline {
774        write!(out, "\x1b[4m")?;
775    }
776    if style.inverse {
777        write!(out, "\x1b[7m")?;
778    }
779    match style.fg {
780        vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
781        vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
782        _ => {}
783    }
784    match style.bg {
785        vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
786        vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
787        _ => {}
788    }
789    Ok(())
790}
791
792pub fn render_frame(
793    out: &mut dyn Write,
794    screen: &Screen,
795    rows: u16,
796    cols: u16,
797    clear_display: bool,
798) -> io::Result<()> {
799    let mut buf =
800        Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
801    let mut active_style = CellStyle::default();
802
803    // Synchronized Output begin, hide cursor, reset attributes
804    buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
805    if clear_display {
806        buf.extend_from_slice(b"\x1b[2J");
807    }
808    buf.extend_from_slice(b"\x1b[?7l");
809
810    for row in 0..rows {
811        write!(buf, "\x1b[{};1H", row + 1)?;
812
813        let mut col: u16 = 0;
814        while col < cols {
815            // Compute cell width first to handle wide chars (CJK, emoji) that
816            // span multiple columns — checking col + width >= cols catches the
817            // right-edge case even when a wide char at cols-2 jumps past cols-1.
818            let cell_opt = screen.cell(row, col);
819            let contents = cell_opt.map_or("", |c| c.contents());
820            let width = if contents.is_empty() {
821                1
822            } else {
823                unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
824            };
825
826            // Margin sanitation: clear right margin before writing the cell that
827            // touches or passes the right edge.  Placing \x1b[K here (while the
828            // cursor is still at col) avoids cursor-inclusive erasure of the cell.
829            if col + width >= cols {
830                buf.extend_from_slice(b"\x1b[0m\x1b[K");
831                active_style = CellStyle::default();
832            }
833
834            let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
835            if style != active_style {
836                apply_sgr(&mut buf, &style)?;
837                active_style = style;
838            }
839
840            if contents.is_empty() {
841                buf.push(b' ');
842            } else {
843                buf.extend_from_slice(contents.as_bytes());
844            }
845
846            col += width;
847        }
848    }
849
850    buf.extend_from_slice(b"\x1b[?7h");
851    buf.extend_from_slice(b"\x1b[0m");
852    let (cur_row, cur_col) = screen.cursor_position();
853    write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
854    if screen.hide_cursor() {
855        buf.extend_from_slice(b"\x1b[?25l");
856    } else {
857        buf.extend_from_slice(b"\x1b[?25h");
858    }
859    // Synchronized Output end — terminal now paints atomically
860    buf.extend_from_slice(b"\x1b[?2026l");
861
862    out.write_all(&buf)?;
863    out.flush()
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869    use std::sync::{Arc, Mutex};
870
871    use term_wm_events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
872
873    struct TestWriter {
874        buf: Arc<Mutex<Vec<u8>>>,
875    }
876
877    impl TestWriter {
878        fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
879            let buf = Arc::new(Mutex::new(Vec::new()));
880            (Self { buf: buf.clone() }, buf)
881        }
882    }
883
884    impl Write for TestWriter {
885        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
886            self.buf.lock().unwrap().extend_from_slice(buf);
887            Ok(buf.len())
888        }
889        fn flush(&mut self) -> io::Result<()> {
890            Ok(())
891        }
892    }
893
894    /// Calls the real `init_terminal()` with a test writer and verifies
895    /// the bracketed paste enable sequence `\x1b[?2004h` is written.
896    /// Under `cargo test` stdin is a pipe, so `is_terminal()` returns false
897    /// and the raw-mode OS call is skipped — only the ANSI output matters.
898    #[test]
899    fn init_terminal_writes_bracketed_paste_enable() {
900        let (writer, buf) = TestWriter::new();
901        let _guard = init_terminal(writer).expect("init_terminal");
902        let bytes = buf.lock().unwrap();
903        assert!(
904            bytes
905                .windows(b"\x1b[?2004h".len())
906                .any(|w| w == b"\x1b[?2004h")
907        );
908    }
909
910    // ── client identity helpers ─────────────────────────────────────
911    //
912    // These mutate `SSH_CLIENT`/`SSH_CONNECTION`, which is process-global;
913    // a static mutex serializes them against other tests (and each other).
914
915    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
916        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
917        LOCK.lock().unwrap_or_else(|e| e.into_inner())
918    }
919
920    #[test]
921    fn client_ssh_ip_from_ssh_client() {
922        let _guard = env_lock();
923        unsafe {
924            std::env::set_var("SSH_CLIENT", "192.168.1.50 54321 22");
925            std::env::remove_var("SSH_CONNECTION");
926        }
927        assert_eq!(client_ssh_ip().as_deref(), Some("192.168.1.50"));
928        unsafe {
929            std::env::remove_var("SSH_CLIENT");
930        }
931    }
932
933    #[test]
934    fn client_ssh_ip_from_ssh_connection_fallback() {
935        let _guard = env_lock();
936        unsafe {
937            std::env::remove_var("SSH_CLIENT");
938            std::env::set_var("SSH_CONNECTION", "10.0.0.7 48000 10.0.0.1 22");
939        }
940        assert_eq!(client_ssh_ip().as_deref(), Some("10.0.0.7"));
941        unsafe {
942            std::env::remove_var("SSH_CONNECTION");
943        }
944    }
945
946    #[test]
947    fn client_ssh_ip_ssh_client_wins_over_connection() {
948        let _guard = env_lock();
949        unsafe {
950            std::env::set_var("SSH_CLIENT", "1.2.3.4 1000 22");
951            std::env::set_var("SSH_CONNECTION", "9.9.9.9 2000 1.1.1.1 22");
952        }
953        assert_eq!(client_ssh_ip().as_deref(), Some("1.2.3.4"));
954        unsafe {
955            std::env::remove_var("SSH_CLIENT");
956            std::env::remove_var("SSH_CONNECTION");
957        }
958    }
959
960    #[test]
961    fn client_ssh_ip_none_when_local() {
962        let _guard = env_lock();
963        unsafe {
964            std::env::remove_var("SSH_CLIENT");
965            std::env::remove_var("SSH_CONNECTION");
966        }
967        assert_eq!(client_ssh_ip(), None);
968    }
969
970    #[test]
971    fn client_version_matches_package() {
972        assert_eq!(client_version(), env!("CARGO_PKG_VERSION"));
973    }
974
975    #[test]
976    fn client_user_non_empty() {
977        assert!(!client_user().is_empty(), "client user must resolve");
978    }
979
980    #[test]
981    #[cfg(windows)]
982    fn client_user_prefers_username_env_when_set() {
983        let _guard = env_lock();
984        unsafe {
985            std::env::set_var("USERNAME", "win-test-user");
986        }
987        assert_eq!(client_user(), "win-test-user");
988        unsafe {
989            std::env::remove_var("USERNAME");
990        }
991    }
992
993    #[test]
994    #[cfg(windows)]
995    fn client_user_falls_back_to_getusername_when_env_absent() {
996        let _guard = env_lock();
997        unsafe {
998            std::env::remove_var("USERNAME");
999        }
1000        // `USERNAME` is normally always set on Windows; with it removed, the
1001        // `GetUserNameW` fallback must still resolve the real account.
1002        assert!(
1003            !client_user().is_empty(),
1004            "GetUserNameW fallback must resolve a user"
1005        );
1006    }
1007
1008    /// Constructs a TerminalGuard with a test writer and verifies that
1009    /// dropping it writes the bracketed paste disable sequence `\x1b[?2004l`.
1010    #[test]
1011    fn terminal_guard_teardown_writes_bracketed_paste_disable() {
1012        let (writer, buf) = TestWriter::new();
1013        {
1014            let _guard = TerminalGuard {
1015                writer: Some(writer),
1016            };
1017        }
1018        let bytes = buf.lock().unwrap();
1019        assert!(
1020            bytes
1021                .windows(b"\x1b[?2004l".len())
1022                .any(|w| w == b"\x1b[?2004l")
1023        );
1024    }
1025
1026    /// Full lifecycle: init_terminal followed by TerminalGuard teardown
1027    /// writes both the enable and disable sequences.
1028    #[test]
1029    fn init_and_teardown_roundtrip_contains_both_sequences() {
1030        let (writer, buf) = TestWriter::new();
1031        let guard = init_terminal(writer).expect("init_terminal");
1032        drop(guard);
1033        let bytes = buf.lock().unwrap();
1034        assert!(
1035            bytes
1036                .windows(b"\x1b[?2004h".len())
1037                .any(|w| w == b"\x1b[?2004h")
1038        );
1039        assert!(
1040            bytes
1041                .windows(b"\x1b[?2004l".len())
1042                .any(|w| w == b"\x1b[?2004l")
1043        );
1044    }
1045
1046    /// Proves that reusing a parser via set_size + RIS + process yields
1047    /// identical screen state to a freshly allocated parser.
1048    #[test]
1049    fn test_prev_parser_resize_sync_matches_fresh_parser() {
1050        let mut prev_parser = vt100::Parser::new(24, 80, 0);
1051        prev_parser.process(b"initial screen content");
1052
1053        // Simulate terminal window resize to 40x120
1054        let (new_rows, new_cols) = (40, 120);
1055        let new_formatted_content = {
1056            let mut p = vt100::Parser::new(new_rows, new_cols, 0);
1057            p.process(b"resized screen content");
1058            p.screen().contents_formatted().to_vec()
1059        };
1060
1061        // Re-use prev_parser using dimension sync + RIS reset
1062        prev_parser.screen_mut().set_size(new_rows, new_cols);
1063        prev_parser.process(b"\x1bc");
1064        prev_parser.process(&new_formatted_content);
1065
1066        // Verify against a freshly created parser
1067        let mut fresh_parser = vt100::Parser::new(new_rows, new_cols, 0);
1068        fresh_parser.process(&new_formatted_content);
1069
1070        assert_eq!(
1071            prev_parser.screen().contents_formatted(),
1072            fresh_parser.screen().contents_formatted(),
1073            "Reused parser state after set_size + RIS must match fresh parser"
1074        );
1075    }
1076
1077    #[test]
1078    fn render_frame_outputs_correct_cup_and_sgr() {
1079        let mut parser = vt100::Parser::new(4, 8, 0);
1080        parser.process(b"\x1b[31mhello\x1b[0m");
1081        let screen = parser.screen();
1082        let mut buf: Vec<u8> = Vec::new();
1083        let (rows, cols) = screen.size();
1084        render_frame(&mut buf, screen, rows, cols, false).unwrap();
1085        let output = String::from_utf8_lossy(&buf);
1086        // Should contain CUP to each row (4 rows)
1087        assert!(output.contains("\x1b[1;1H"));
1088        assert!(output.contains("\x1b[2;1H"));
1089        assert!(output.contains("\x1b[3;1H"));
1090        assert!(output.contains("\x1b[4;1H"));
1091        // Should contain "hello"
1092        assert!(output.contains("hello"));
1093        // Should contain red foreground SGR
1094        assert!(
1095            output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
1096            "Expected red foreground SGR in output: {output:?}"
1097        );
1098        // Should not contain raw ESC characters without following sequences
1099        assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
1100    }
1101
1102    // ── is_coalescable_mouse tests ────────────────────────────────────────
1103
1104    #[test]
1105    fn coalesce_moved_with_moved() {
1106        assert!(is_coalescable_mouse(
1107            &MouseEventKind::Moved,
1108            &KeyModifiers::NONE,
1109            &MouseEventKind::Moved,
1110            &KeyModifiers::NONE,
1111        ));
1112    }
1113
1114    #[test]
1115    fn coalesce_drag_same_button() {
1116        assert!(is_coalescable_mouse(
1117            &MouseEventKind::Drag(MouseButton::Left),
1118            &KeyModifiers::NONE,
1119            &MouseEventKind::Drag(MouseButton::Left),
1120            &KeyModifiers::NONE,
1121        ));
1122        assert!(is_coalescable_mouse(
1123            &MouseEventKind::Drag(MouseButton::Right),
1124            &KeyModifiers {
1125                shift: true,
1126                ..KeyModifiers::NONE
1127            },
1128            &MouseEventKind::Drag(MouseButton::Right),
1129            &KeyModifiers {
1130                shift: true,
1131                ..KeyModifiers::NONE
1132            },
1133        ));
1134    }
1135
1136    #[test]
1137    fn reject_drag_different_button() {
1138        assert!(!is_coalescable_mouse(
1139            &MouseEventKind::Drag(MouseButton::Left),
1140            &KeyModifiers::NONE,
1141            &MouseEventKind::Drag(MouseButton::Right),
1142            &KeyModifiers::NONE,
1143        ));
1144    }
1145
1146    #[test]
1147    fn reject_moved_vs_drag() {
1148        assert!(!is_coalescable_mouse(
1149            &MouseEventKind::Moved,
1150            &KeyModifiers::NONE,
1151            &MouseEventKind::Drag(MouseButton::Left),
1152            &KeyModifiers::NONE,
1153        ));
1154    }
1155
1156    #[test]
1157    fn reject_different_modifiers() {
1158        assert!(!is_coalescable_mouse(
1159            &MouseEventKind::Moved,
1160            &KeyModifiers::NONE,
1161            &MouseEventKind::Moved,
1162            &KeyModifiers {
1163                shift: true,
1164                ..KeyModifiers::NONE
1165            },
1166        ));
1167        assert!(!is_coalescable_mouse(
1168            &MouseEventKind::Drag(MouseButton::Left),
1169            &KeyModifiers {
1170                control: true,
1171                ..KeyModifiers::NONE
1172            },
1173            &MouseEventKind::Drag(MouseButton::Left),
1174            &KeyModifiers::NONE,
1175        ));
1176    }
1177
1178    #[test]
1179    fn reject_discrete_events() {
1180        assert!(!is_coalescable_mouse(
1181            &MouseEventKind::Press(MouseButton::Left),
1182            &KeyModifiers::NONE,
1183            &MouseEventKind::Press(MouseButton::Left),
1184            &KeyModifiers::NONE,
1185        ));
1186        assert!(!is_coalescable_mouse(
1187            &MouseEventKind::Release(MouseButton::Left),
1188            &KeyModifiers::NONE,
1189            &MouseEventKind::Moved,
1190            &KeyModifiers::NONE,
1191        ));
1192        assert!(!is_coalescable_mouse(
1193            &MouseEventKind::Moved,
1194            &KeyModifiers::NONE,
1195            &MouseEventKind::ScrollDown,
1196            &KeyModifiers::NONE,
1197        ));
1198        assert!(!is_coalescable_mouse(
1199            &MouseEventKind::ScrollUp,
1200            &KeyModifiers::NONE,
1201            &MouseEventKind::ScrollUp,
1202            &KeyModifiers::NONE,
1203        ));
1204    }
1205
1206    // ── Coalescing loop integration tests ─────────────────────────────────
1207
1208    /// Helper: run the coalescing logic from the main loop against a real
1209    /// bounded channel, returning the final event (or None if filtered away).
1210    fn coalesce_through(
1211        events: &[Event],
1212        kind: MouseEventKind,
1213        modifiers: KeyModifiers,
1214    ) -> Option<Event> {
1215        let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
1216        for e in events.iter().cloned() {
1217            tx.send(e).ok();
1218        }
1219        drop(tx);
1220
1221        let mut result = Event::Mouse(MouseEvent {
1222            kind,
1223            modifiers,
1224            column: 0,
1225            row: 0,
1226        });
1227
1228        if let Event::Mouse(ref mut mouse) = result
1229            && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
1230        {
1231            while let Ok(next) = rx.try_recv() {
1232                match next {
1233                    Event::Mouse(ref next_mouse)
1234                        if is_coalescable_mouse(
1235                            &mouse.kind,
1236                            &mouse.modifiers,
1237                            &next_mouse.kind,
1238                            &next_mouse.modifiers,
1239                        ) =>
1240                    {
1241                        *mouse = *next_mouse;
1242                    }
1243                    _other => return Some(result),
1244                }
1245            }
1246        }
1247
1248        Some(result)
1249    }
1250
1251    #[test]
1252    fn coalesce_keeps_latest_moved_position() {
1253        let events = vec![
1254            Event::Mouse(MouseEvent {
1255                kind: MouseEventKind::Moved,
1256                modifiers: KeyModifiers::NONE,
1257                column: 5,
1258                row: 5,
1259            }),
1260            Event::Mouse(MouseEvent {
1261                kind: MouseEventKind::Moved,
1262                modifiers: KeyModifiers::NONE,
1263                column: 10,
1264                row: 10,
1265            }),
1266            Event::Mouse(MouseEvent {
1267                kind: MouseEventKind::Moved,
1268                modifiers: KeyModifiers::NONE,
1269                column: 15,
1270                row: 15,
1271            }),
1272        ];
1273        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1274        let Event::Mouse(m) = result.unwrap() else {
1275            panic!("expected mouse")
1276        };
1277        assert_eq!((m.column, m.row), (15, 15));
1278    }
1279
1280    #[test]
1281    fn coalesce_keeps_latest_drag_position() {
1282        let events = vec![
1283            Event::Mouse(MouseEvent {
1284                kind: MouseEventKind::Drag(MouseButton::Left),
1285                modifiers: KeyModifiers::NONE,
1286                column: 1,
1287                row: 1,
1288            }),
1289            Event::Mouse(MouseEvent {
1290                kind: MouseEventKind::Drag(MouseButton::Left),
1291                modifiers: KeyModifiers::NONE,
1292                column: 2,
1293                row: 2,
1294            }),
1295        ];
1296        let result = coalesce_through(
1297            &events,
1298            MouseEventKind::Drag(MouseButton::Left),
1299            KeyModifiers::NONE,
1300        );
1301        let Event::Mouse(m) = result.unwrap() else {
1302            panic!("expected mouse")
1303        };
1304        assert_eq!((m.column, m.row), (2, 2));
1305    }
1306
1307    #[test]
1308    fn coalesce_stops_at_modifier_change() {
1309        let events = vec![Event::Mouse(MouseEvent {
1310            kind: MouseEventKind::Moved,
1311            modifiers: KeyModifiers {
1312                shift: true,
1313                ..KeyModifiers::NONE
1314            },
1315            column: 99,
1316            row: 99,
1317        })];
1318        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1319        let Event::Mouse(m) = result.unwrap() else {
1320            panic!("expected mouse")
1321        };
1322        // The first event (modifier change) should NOT be consumed — we
1323        // still hold the original event at (0,0) with NONE modifiers.
1324        assert_eq!((m.column, m.row), (0, 0));
1325    }
1326
1327    #[test]
1328    fn coalesce_stops_at_non_mouse_event() {
1329        let key = Event::Key(KeyEvent {
1330            code: KeyCode::Char('q'),
1331            kind: KeyKind::Press,
1332            modifiers: KeyModifiers::NONE,
1333        });
1334        let events = vec![key.clone()];
1335        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1336        let Event::Mouse(m) = result.unwrap() else {
1337            panic!("expected mouse")
1338        };
1339        // Should retain original event, not consuming the key
1340        assert_eq!((m.column, m.row), (0, 0));
1341    }
1342
1343    #[test]
1344    fn coalesce_stops_at_discrete_mouse_event() {
1345        let events = vec![Event::Mouse(MouseEvent {
1346            kind: MouseEventKind::Press(MouseButton::Left),
1347            modifiers: KeyModifiers::NONE,
1348            column: 10,
1349            row: 10,
1350        })];
1351        let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1352        let Event::Mouse(m) = result.unwrap() else {
1353            panic!("expected mouse")
1354        };
1355        // Should NOT consume the Press event
1356        assert_eq!((m.column, m.row), (0, 0));
1357    }
1358}
1359
1360// ── Snapshot tests for render_frame byte output ─────────────────────────
1361// Uses a push_rx mock (crossbeam channel) + RemotePane(client: None) for
1362// deterministic, non-flaky byte-stream assertions.
1363#[cfg(test)]
1364#[allow(clippy::type_complexity)]
1365mod snapshot_tests {
1366    use super::*;
1367
1368    /// Render a screen from deterministic PTY bytes, capturing the raw
1369    /// ANSI output.
1370    fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
1371        let rt = tokio::runtime::Builder::new_current_thread()
1372            .build()
1373            .expect("tokio rt");
1374        let (push_tx, push_rx) = crossbeam_channel::bounded(16);
1375        let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
1376        let mut pane = RemotePane::new(
1377            0,
1378            None,
1379            rt.handle().clone(),
1380            cols,
1381            rows,
1382            push_rx,
1383            input_writer,
1384        );
1385        drop(rt); // rt must outlive the channels but not RemotePane
1386
1387        push_tx.send(pty_bytes.to_vec()).ok();
1388        pane.drain_pushes();
1389
1390        let parser = pane.shared_parser();
1391        let parser = parser.lock().unwrap();
1392        let screen = parser.screen();
1393        let (rows, cols) = screen.size();
1394        let mut out = Vec::new();
1395        render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
1396        out
1397    }
1398
1399    /// Escape ANSI and control bytes for readable snapshot diffs.
1400    fn escape_ansi(bytes: &[u8]) -> String {
1401        let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
1402        for &b in bytes {
1403            match b {
1404                b'\x1b' => out.extend_from_slice(b"\\x1b"),
1405                b'\n' => out.extend_from_slice(b"\\n"),
1406                b'\r' => out.extend_from_slice(b"\\r"),
1407                b'\t' => out.extend_from_slice(b"\\t"),
1408                0x20..=0x7e => out.push(b),
1409                _ => {
1410                    out.push(b'\\');
1411                    out.push(b'x');
1412                    out.extend_from_slice(&hex_byte(b));
1413                }
1414            }
1415        }
1416        // SAFETY: all bytes are valid ASCII (0x20-0x7e or escaped sequences)
1417        unsafe { String::from_utf8_unchecked(out) }
1418    }
1419
1420    fn hex_byte(b: u8) -> [u8; 2] {
1421        #[inline]
1422        fn hex_nibble(n: u8) -> u8 {
1423            let digit = n & 0x0f;
1424            if digit < 10 {
1425                b'0' + digit
1426            } else {
1427                b'a' + digit - 10
1428            }
1429        }
1430        [hex_nibble(b >> 4), hex_nibble(b)]
1431    }
1432
1433    // ── Tests ────────────────────────────────────────────────────────
1434
1435    #[test]
1436    fn snapshot_empty_grid() {
1437        let out = render_and_capture(b"", 4, 8, false);
1438        insta::assert_snapshot!("empty_grid", escape_ansi(&out));
1439    }
1440
1441    #[test]
1442    fn snapshot_basic_text() {
1443        let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
1444        insta::assert_snapshot!("basic_text", escape_ansi(&out));
1445    }
1446
1447    #[test]
1448    fn snapshot_colored_text() {
1449        let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
1450        insta::assert_snapshot!("colored_text", escape_ansi(&out));
1451    }
1452
1453    #[test]
1454    fn snapshot_normal_char_at_margin() {
1455        // Fill a 4-wide grid so the last column contains 'D' — triggers
1456        // margin sanitation before the final cell in the row.
1457        let out = render_and_capture(b"ABCD", 1, 4, false);
1458        insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
1459    }
1460
1461    #[test]
1462    fn snapshot_clear_display() {
1463        let out = render_and_capture(b"", 4, 8, true);
1464        insta::assert_snapshot!("clear_display", escape_ansi(&out));
1465    }
1466
1467    #[test]
1468    fn snapshot_hidden_cursor() {
1469        let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
1470        insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
1471    }
1472
1473    #[test]
1474    fn snapshot_color_across_margin() {
1475        // Red background on a block char right at the last column.
1476        // Verify \x1b[0m resets the color before \x1b[K clears the margin.
1477        let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
1478        insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
1479    }
1480
1481    #[test]
1482    fn snapshot_multi_row_fill() {
1483        // Fill all 4×3 cells with unique chars to verify row-by-row CUP +
1484        // margin sanitation on every row.
1485        let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
1486        insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
1487    }
1488
1489    #[test]
1490    fn snapshot_wide_char_margin() {
1491        // Use 3-wide grid with CJK at col 1 (width 2, fills cols 1-2).
1492        // Margin check: 1 + 2 = 3 >= 3 → \x1b[K fires before the char.
1493        let out = render_and_capture(
1494            b"B\xe3\x81\x82", // HIRAGANA A (U+3042, width 2 in unicode-width)
1495            1,
1496            3,
1497            false,
1498        );
1499        insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
1500    }
1501
1502    #[test]
1503    fn snapshot_wide_char_middle() {
1504        // Wide char at col 1 in a 5-wide grid (cols 1-2).  Does NOT
1505        // trigger margin sanitation (1 + 2 = 3 < 5) — verifies wide
1506        // chars render correctly in the middle of a row.
1507        let out = render_and_capture(
1508            b"A\xe3\x81\x82\xe3\x81\x83", // CJK chars at col 1 and col 3
1509            1,
1510            5,
1511            false,
1512        );
1513        insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1514    }
1515}