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