Skip to main content

term_session_client/
lib.rs

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