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