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