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