1#![doc = include_str!("../README.md")]
2
3mod remote_pane;
4
5pub use remote_pane::RemotePane;
6
7use std::io::{self, IsTerminal, Write, stdout};
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_clipboard::{Clipboard, Osc52Extractor};
23use term_session_muxio_service_definitions::{
24 Attach, AttachRequest, OnPtyResized, OnWorkspaceRebind, RpcMethodPrebuffered,
25 STREAM_INPUT_METHOD_ID, SUBSCRIBE_OUTPUT_METHOD_ID, Spawn, SpawnRequest, SpawnResponse,
26 path_wire,
27};
28#[cfg(unix)]
29use term_sys_io::redirect_fd_to_tracing;
30use term_wm_events::{Event, KeyKind, KeyModifiers, MouseEventKind};
31use term_wm_pty_engine::Pane;
32use term_wm_pty_engine::input_encoding::{key_to_bytes, mouse_event_to_bytes};
33use term_wm_pty_engine::signal::install_sigint_handler;
34use term_wm_vt100::{MouseProtocolEncoding, MouseProtocolMode, Parser, Screen};
35
36#[cfg(windows)]
40fn disable_quick_edit() {
41 use windows_sys::Win32::System::Console::{
42 ENABLE_EXTENDED_FLAGS, ENABLE_QUICK_EDIT_MODE, GetConsoleMode, GetStdHandle,
43 STD_INPUT_HANDLE, SetConsoleMode,
44 };
45
46 unsafe {
47 let handle = GetStdHandle(STD_INPUT_HANDLE);
48 let mut mode: u32 = 0;
49 if GetConsoleMode(handle, &mut mode) != 0 {
50 let _ = SetConsoleMode(
51 handle,
52 (mode & !ENABLE_QUICK_EDIT_MODE) | ENABLE_EXTENDED_FLAGS,
53 );
54 }
55 }
56}
57
58#[cfg(target_os = "windows")]
61const INITIAL_WAIT_ITERS: usize = 60;
62#[cfg(not(target_os = "windows"))]
63const INITIAL_WAIT_ITERS: usize = 20;
64
65const PTY_OUTPUT_CHANNEL_CAPACITY: usize = 256;
67const CLIPBOARD_CHANNEL_CAPACITY: usize = 64;
69const INPUT_CHANNEL_CAPACITY: usize = 64;
71
72const PREV_TAIL_LEN: usize = 8;
76
77const INITIAL_WAIT_SLEEP_MS: u64 = 50;
79
80const INPUT_POLL_MS: u64 = 50;
83
84const BACKPRESSURE_SLEEP_MS: u64 = 1;
87
88const BRACKETED_PASTE_OVERHEAD: usize = 12;
91
92const RENDER_BUF_CELL_MULTIPLIER: usize = 3;
94
95const MIN_TERM_COLS: u16 = 2;
99const MIN_TERM_ROWS: u16 = 2;
100
101const FALLBACK_TERM_COLS: u16 = 80;
105const FALLBACK_TERM_ROWS: u16 = 24;
106
107pub fn nested_session_fatal_error(app_name: &str) -> io::Error {
109 io::Error::other(format!(
110 "FATAL: Attempted to run {app_name} inside an existing session environment on the same gateway.\n\
111 Session inception can cause terminal buffer corruption and accidental gateway stops.\n\n\
112 To force nested execution, rerun with:\n \
113 {app_name} --allow-nested [args...]"
114 ))
115}
116
117pub fn is_nested_session_fatal(err: &io::Error) -> bool {
119 err.to_string().contains("FATAL: Attempted to run")
120}
121
122fn should_block_nesting(
126 active_gateway: Option<&str>,
127 target_socket: &str,
128 allow_nested: bool,
129) -> bool {
130 if allow_nested {
131 return false;
132 }
133 match active_gateway {
134 Some(active) => active == target_socket,
135 None => false,
136 }
137}
138
139pub fn init_terminal<W: Write>(mut writer: W) -> io::Result<TerminalGuard<W>> {
147 if std::io::stdin().is_terminal() {
148 enable_raw_mode()?;
149 }
150 writer.queue(EnterAlternateScreen)?;
151 writer.queue(Hide)?;
152 writer.queue(EnableBracketedPaste)?;
153 writer.queue(crossterm::event::EnableMouseCapture)?;
154 #[cfg(windows)]
162 term_wm_crossterm_adapter::set_mouse_capture_with(&mut writer, true)?;
163 writer.flush()?;
164 Ok(TerminalGuard {
165 writer: Some(writer),
166 })
167}
168
169pub 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 #[cfg(windows)]
183 let _ = term_wm_crossterm_adapter::set_mouse_capture_with(writer, false);
184 let _ = writer.queue(DisableBracketedPaste);
185 let _ = writer.queue(Show);
186 let _ = writer.queue(LeaveAlternateScreen);
187 if std::io::stdin().is_terminal() {
188 let _ = disable_raw_mode();
189 }
190 let _ = writer.flush();
191 }
192 }
193}
194
195fn convert_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
197 term_wm_crossterm_adapter::try_translate_event(evt)
198}
199
200fn attributed_mouse_is_motion(e: &Event) -> bool {
205 matches!(
206 e,
207 Event::Mouse(m) if matches!(m.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
208 )
209}
210
211fn attributed_mouse_coalescable(a: &Event, b: &Event) -> bool {
214 match (a, b) {
215 (Event::Mouse(am), Event::Mouse(bm)) => {
216 is_coalescable_mouse(&am.kind, &am.modifiers, &bm.kind, &bm.modifiers)
217 }
218 _ => false,
219 }
220}
221
222fn is_coalescable_mouse(
223 a_kind: &MouseEventKind,
224 a_mod: &KeyModifiers,
225 b_kind: &MouseEventKind,
226 b_mod: &KeyModifiers,
227) -> bool {
228 if a_mod != b_mod {
229 return false;
230 }
231 match (a_kind, b_kind) {
232 (MouseEventKind::Moved, MouseEventKind::Moved) => true,
233 (MouseEventKind::Drag(btn1), MouseEventKind::Drag(btn2)) => btn1 == btn2,
234 _ => false,
235 }
236}
237
238fn client_user() -> String {
243 #[cfg(unix)]
244 {
245 unsafe {
246 let pw = libc::getpwuid(libc::getuid());
247 if !pw.is_null() {
248 let name = std::ffi::CStr::from_ptr((*pw).pw_name);
249 if let Ok(s) = name.to_str()
250 && !s.is_empty()
251 {
252 return s.to_string();
253 }
254 }
255 }
256 std::env::var("USER").unwrap_or_default()
257 }
258 #[cfg(windows)]
259 {
260 if let Ok(u) = std::env::var("USERNAME")
261 && !u.is_empty()
262 {
263 return u;
264 }
265 windows_username().unwrap_or_default()
266 }
267 #[cfg(not(any(unix, windows)))]
268 {
269 String::new()
270 }
271}
272
273#[cfg(windows)]
276fn windows_username() -> Option<String> {
277 use std::os::windows::ffi::OsStringExt;
278 use windows_sys::Win32::System::WindowsProgramming::GetUserNameW;
279 let mut buf = [0u16; 256];
280 let mut len = buf.len() as u32;
281 let ok = unsafe { GetUserNameW(buf.as_mut_ptr(), &mut len) };
282 if ok == 0 {
283 return None;
284 }
285 let s = std::ffi::OsString::from_wide(&buf[..len as usize])
286 .to_string_lossy()
287 .into_owned();
288 if s.is_empty() { None } else { Some(s) }
289}
290
291fn client_version() -> String {
294 env!("CARGO_PKG_VERSION").to_string()
295}
296
297fn client_ssh_ip() -> Option<String> {
301 for var in ["SSH_CLIENT", "SSH_CONNECTION"] {
302 if let Ok(v) = std::env::var(var) {
303 let ip = v.split_whitespace().next()?;
304 if !ip.is_empty() {
305 return Some(ip.to_string());
306 }
307 }
308 }
309 None
310}
311
312fn client_ssh_port() -> Option<u16> {
316 for var in ["SSH_CLIENT", "SSH_CONNECTION"] {
317 if let Ok(v) = std::env::var(var)
318 && let Some(port_str) = v.split_whitespace().nth(1)
319 && let Ok(port) = port_str.parse::<u16>()
320 {
321 return Some(port);
322 }
323 }
324 None
325}
326
327pub fn run_session(
338 socket_path: &str,
339 channel: &str,
340 cmd: &[String],
341 allow_nested: bool,
342 app_name: &str,
343) -> io::Result<Option<String>> {
344 let host_gateway =
350 std::env::var(term_session_muxio_service_definitions::SESSION_GATEWAY_ENV_VAR).ok();
351 if should_block_nesting(host_gateway.as_deref(), socket_path, allow_nested) {
352 return Err(nested_session_fatal_error(app_name));
353 }
354
355 #[cfg(windows)]
360 disable_quick_edit();
361
362 let rt =
363 tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
364
365 let client: Arc<RpcIpcClient> = rt
367 .block_on(RpcIpcClient::new(socket_path))
368 .map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, format!("{e:?}")))?;
369
370 let abi_fault = |e: &dyn std::fmt::Display| -> io::Error {
374 io::Error::other(format!(
375 "FATAL: Protocol ABI mismatch. A legacy daemon may be occupying the IPC socket. Manually terminate the daemon process before continuing. (cause: {e})"
376 ))
377 };
378
379 let server_cols = Arc::new(AtomicU16::new(0));
383 let server_rows = Arc::new(AtomicU16::new(0));
384 let resize_pending = Arc::new(AtomicBool::new(false));
385
386 {
387 let cols_ref = Arc::clone(&server_cols);
388 let rows_ref = Arc::clone(&server_rows);
389 let pending_ref = Arc::clone(&resize_pending);
390 rt.block_on(client.get_endpoint().register_prebuffered(
391 OnPtyResized::METHOD_ID,
392 move |payload, _ctx| {
393 let cols_ref = Arc::clone(&cols_ref);
394 let rows_ref = Arc::clone(&rows_ref);
395 let pending_ref = Arc::clone(&pending_ref);
396 async move {
397 let (cols, rows) = OnPtyResized::decode_request(&payload)
398 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
399 cols_ref.store(cols, Ordering::Relaxed);
400 rows_ref.store(rows, Ordering::Relaxed);
401 pending_ref.store(true, Ordering::Relaxed);
402 OnPtyResized::encode_response(())
403 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
404 }
405 },
406 ))
407 .map_err(|e| io::Error::other(format!("register OnPtyResized: {e:?}")))?;
408 }
409
410 let rebind_target: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
413
414 {
415 let target_ref = Arc::clone(&rebind_target);
416 rt.block_on(client.get_endpoint().register_prebuffered(
417 OnWorkspaceRebind::METHOD_ID,
418 move |payload, _ctx| {
419 let target_ref = Arc::clone(&target_ref);
420 async move {
421 let req = OnWorkspaceRebind::decode_request(&payload)
422 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
423 *target_ref.lock().unwrap_or_else(|err| err.into_inner()) = Some(req.target);
424 OnWorkspaceRebind::encode_response(())
425 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
426 }
427 },
428 ))
429 .map_err(|e| io::Error::other(format!("register OnWorkspaceRebind: {e:?}")))?;
430 }
431
432 let (push_tx, push_rx) = crossbeam_channel::bounded::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
436 let (clip_tx, clip_rx) = crossbeam_channel::bounded::<String>(CLIPBOARD_CHANNEL_CAPACITY);
437
438 let (term_cols, term_rows) = match crossterm::terminal::size() {
445 Ok((c, r)) => (c.max(MIN_TERM_COLS), r.max(MIN_TERM_ROWS)),
446 Err(_) => (FALLBACK_TERM_COLS, FALLBACK_TERM_ROWS),
447 };
448 let hostname = hostname::get()
449 .map(|h| h.to_string_lossy().into_owned())
450 .unwrap_or_else(|_| "unknown".to_string());
451
452 let (actual_cols, actual_rows) = rt.block_on(async {
453 let conn_id = Attach::call(
456 &*client,
457 AttachRequest {
458 channel: channel.to_string(),
459 hostname,
460 pid: std::process::id() as u64,
461 user: client_user(),
462 version: client_version(),
463 ssh_ip: client_ssh_ip(),
464 ssh_port: client_ssh_port(),
465 },
466 )
467 .await
468 .map_err(|e| abi_fault(&e))?;
469 let cmd = if cmd.is_empty() {
471 None
472 } else {
473 Some(cmd.to_vec())
474 };
475 let launch_cwd = std::env::current_dir().ok().map(path_wire::encode_path);
481 let SpawnResponse {
482 id: _session_id,
483 cols: actual_cols,
484 rows: actual_rows,
485 ..
486 } = Spawn::call(
487 &*client,
488 SpawnRequest {
489 cmd,
490 cols: term_cols,
491 rows: term_rows,
492 cwd: launch_cwd,
493 },
494 )
495 .await
496 .map_err(|e| abi_fault(&e))?;
497 let _ = conn_id;
498 Ok::<(u16, u16), io::Error>((actual_cols, actual_rows))
499 })?;
500
501 let writer = rt.block_on(async {
503 let (_, mut reader) = client
506 .open_channel(SUBSCRIBE_OUTPUT_METHOD_ID, 0)
507 .await
508 .map_err(|e| io::Error::other(format!("subscribe: {e:?}")))?;
509
510 rt.spawn(async move {
514 let mut osc52 = Osc52Extractor::new();
515 let mut prev_tail: [u8; PREV_TAIL_LEN] = [0; PREV_TAIL_LEN];
516
517 while let Some(chunk) = reader.recv().await {
518 if let Ok(mut data) = chunk {
519 if let Some(text) = osc52.push(&data, &prev_tail) {
520 let _ = clip_tx.try_send(text);
521 }
522
523 let n = data.len();
524 if n >= PREV_TAIL_LEN {
525 prev_tail.copy_from_slice(&data[n - PREV_TAIL_LEN..n]);
526 } else if n > 0 {
527 prev_tail.rotate_left(n);
528 prev_tail[PREV_TAIL_LEN - n..].copy_from_slice(&data[..n]);
529 }
530
531 while let Err(crossbeam_channel::TrySendError::Full(pending)) =
534 push_tx.try_send(data)
535 {
536 data = pending;
537 tokio::time::sleep(Duration::from_millis(BACKPRESSURE_SLEEP_MS)).await;
538 }
539 } else {
540 break;
541 }
542 }
543 if let Some(text) = osc52.finish() {
546 let _ = clip_tx.try_send(text);
547 }
548 });
549
550 let (writer, _) = client
553 .open_channel(STREAM_INPUT_METHOD_ID, 0)
554 .await
555 .map_err(|e| io::Error::other(format!("stream input: {e:?}")))?;
556
557 Ok::<_, io::Error>(writer)
558 })?;
559
560 let input_writer = Box::new(move |data: &[u8]| -> io::Result<()> {
561 writer
562 .send(data.to_vec())
563 .map_err(|e| io::Error::other(e.to_string()))?;
564 Ok(())
565 });
566
567 let mut pane = RemotePane::new(
568 1u64,
569 Some(client.clone()),
570 rt.handle().clone(),
571 term_cols,
572 term_rows,
573 push_rx.clone(),
574 input_writer,
575 );
576
577 let (attributed_tx, mut attributed_rx) = tokio::sync::mpsc::channel::<
580 term_session_muxio_service_definitions::SendAttributedInputRequest,
581 >(1024);
582 {
583 let client_clone = client.clone();
584 rt.spawn(async move {
585 use muxio_rpc_service_caller::prebuffered::RpcCallPrebuffered;
586 use term_session_muxio_service_definitions::SendAttributedInput;
587
588 let mut pending_req = None;
589 loop {
590 let mut req = match pending_req.take() {
591 Some(r) => r,
592 None => match attributed_rx.recv().await {
593 Some(r) => r,
594 None => break,
595 },
596 };
597
598 while attributed_mouse_is_motion(&req.event) {
603 match attributed_rx.try_recv() {
604 Ok(next_req)
605 if attributed_mouse_coalescable(&req.event, &next_req.event) =>
606 {
607 req = next_req;
608 }
609 Ok(next_req) => {
610 pending_req = Some(next_req);
611 break;
612 }
613 Err(_) => break,
614 }
615 }
616 let _ = SendAttributedInput::call(&*client_clone, req).await;
617 }
618 });
619 }
620
621 for _ in 0..INITIAL_WAIT_ITERS {
623 pane.drain_pushes();
624 let parser = pane.shared_parser();
625 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
626 if !parser.screen().contents_formatted().is_empty() {
627 break;
628 }
629 drop(parser);
630 std::thread::sleep(Duration::from_millis(INITIAL_WAIT_SLEEP_MS));
631 }
632
633 {
635 let parser = pane.shared_parser();
636 let mut parser_lk = parser.lock().unwrap_or_else(|e| e.into_inner());
637 let (cur_rows, cur_cols) = parser_lk.screen().size();
638 if actual_cols != cur_cols || actual_rows != cur_rows {
639 parser_lk.screen_mut().set_size(actual_rows, actual_cols);
640 }
641 drop(parser_lk);
642 }
643
644 #[cfg(unix)]
655 let _ = redirect_fd_to_tracing(libc::STDERR_FILENO, true);
656 let _guard = init_terminal(stdout())?;
657 let mut out = stdout();
658
659 let mut clipboard = Clipboard::new();
660 let sigint = install_sigint_handler()?;
661
662 let (input_tx, input_rx) = crossbeam_channel::bounded::<Event>(INPUT_CHANNEL_CAPACITY);
664
665 std::thread::Builder::new()
669 .name("crossterm-input".into())
670 .spawn(move || {
671 loop {
672 match crossterm::event::poll(Duration::from_millis(INPUT_POLL_MS)) {
673 Ok(true) => {
674 if let Ok(crossterm_evt) = crossterm::event::read()
675 && let Some(e) = convert_crossterm_event(crossterm_evt)
676 && input_tx.send(e).is_err()
677 {
678 break;
679 }
680 }
681 Ok(false) => continue,
682 Err(_) => break,
683 }
684 }
685 })
686 .map_err(|e| io::Error::other(format!("spawn input thread: {e}")))?;
687
688 {
690 let parser = pane.shared_parser();
691 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
692 let screen = parser.screen();
693 let (rows, cols) = screen.size();
694 render_frame(&mut out, screen, rows, cols, false)?;
695 }
696
697 let mut pending_input: Option<Event> = None;
698 loop {
699 let mut force_render = false;
700 let mut clear_display = false;
701
702 let apply_pending_resize = |shared_parser: &Arc<Mutex<Parser>>| -> bool {
705 if resize_pending.swap(false, Ordering::Relaxed) {
706 let cols = server_cols.load(Ordering::Relaxed);
707 let rows = server_rows.load(Ordering::Relaxed);
708 if cols > 0 && rows > 0 {
709 let mut parser_lk = shared_parser.lock().unwrap_or_else(|e| e.into_inner());
710 let (cur_rows, cur_cols) = parser_lk.screen().size();
711 if cur_cols != cols || cur_rows != rows {
712 parser_lk.screen_mut().set_size(rows, cols);
713 return true;
714 }
715 }
716 }
717 false
718 };
719
720 let resized = apply_pending_resize(&pane.shared_parser());
722 force_render |= resized;
723 clear_display |= resized;
724
725 if let Some(target) = rebind_target
729 .lock()
730 .unwrap_or_else(|err| err.into_inner())
731 .take()
732 {
733 return Ok(Some(target));
734 }
735
736 let input_event = if let Some(evt) = pending_input.take() {
739 Some(evt)
740 } else {
741 crossbeam_channel::select! {
742 recv(input_rx) -> msg => {
743 match msg {
744 Ok(evt) => Some(evt),
745 Err(_) => return Err(io::Error::other("input thread died")),
746 }
747 }
748 recv(push_rx) -> msg => {
749 match msg {
750 Ok(data) => {
751 let resized = apply_pending_resize(&pane.shared_parser());
756 force_render |= resized;
757 clear_display |= resized;
758
759 let parser = pane.shared_parser();
761 let mut parser = parser.lock().unwrap_or_else(|e| e.into_inner());
762 parser.process(&data);
763 None
764 }
765 Err(_) => {
766 None
769 }
770 }
771 }
772 recv(crossbeam_channel::after(Duration::from_millis(100))) -> _ => {
773 force_render = true;
776 None
777 }
778 }
779 };
780
781 let has_new_data = pane.drain_pushes() || input_event.is_none();
783
784 while let Ok(text) = clip_rx.try_recv() {
786 clipboard.set(&text);
787 }
788
789 if sigint.received() {
791 sigint.ack();
792 let _ = pane.write_bytes(&[0x03]);
793 }
794
795 if let Some(mut evt) = input_event {
797 if let Event::Mouse(ref mut mouse) = evt
802 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
803 {
804 while let Ok(next_evt) = input_rx.try_recv() {
805 match next_evt {
806 Event::Mouse(ref next_mouse)
807 if is_coalescable_mouse(
808 &mouse.kind,
809 &mouse.modifiers,
810 &next_mouse.kind,
811 &next_mouse.modifiers,
812 ) =>
813 {
814 *mouse = *next_mouse;
815 }
816 other => {
817 pending_input = Some(other);
818 break;
819 }
820 }
821 }
822 }
823
824 {
828 use term_session_muxio_service_definitions::SendAttributedInputRequest;
829 let _ = attributed_tx.try_send(SendAttributedInputRequest {
830 channel: channel.to_string(),
831 event: evt.clone(),
832 });
833 }
834
835 match evt {
836 Event::Key(ref key)
837 if key.kind == KeyKind::Press || key.kind == KeyKind::Repeat =>
838 {
839 let bytes = key_to_bytes(key, false);
840 if !bytes.is_empty() {
841 let _ = pane.write_bytes(&bytes);
842 }
843 }
844 Event::Mouse(ref mouse) => {
845 let mouse_active = {
846 let parser = pane.shared_parser();
847 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
848 parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
849 };
850 if mouse_active {
851 let bytes = mouse_event_to_bytes(mouse, MouseProtocolEncoding::Sgr);
852 if !bytes.is_empty() {
853 let _ = pane.write_bytes(&bytes);
854 }
855 }
856 }
857 Event::Resize(w, h) => {
858 let size = PtySize {
859 rows: h,
860 cols: w,
861 pixel_width: 0,
862 pixel_height: 0,
863 };
864 if let Err(err) = pane.resize(size) {
865 tracing::warn!(error = %err, "resize request failed on PTY pane");
866 }
867 force_render = true;
868 clear_display = true;
869 }
870 Event::Paste(text) => {
871 let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
872 wrapped.extend_from_slice(b"\x1b[200~");
873 wrapped.extend_from_slice(text.as_bytes());
874 wrapped.extend_from_slice(b"\x1b[201~");
875 let _ = pane.write_bytes(&wrapped);
876 }
877 _ => {}
878 }
879 }
880
881 if !client.is_connected() {
883 return Err(io::Error::other("connection to session server lost"));
884 }
885
886 if has_new_data || force_render {
888 let parser = pane.shared_parser();
889 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
890 let screen = parser.screen();
891 let (rows, cols) = screen.size();
892 render_frame(&mut out, screen, rows, cols, clear_display)?;
893 }
894
895 if pane.has_exited() {
897 return Ok(None);
898 }
899 }
900}
901
902#[derive(Default, PartialEq, Clone, Copy)]
903struct CellStyle {
904 fg: term_wm_vt100::Color,
905 bg: term_wm_vt100::Color,
906 bold: bool,
907 dim: bool,
908 italic: bool,
909 underline: bool,
910 inverse: bool,
911}
912
913impl CellStyle {
914 fn from_cell(cell: &term_wm_vt100::Cell) -> Self {
915 Self {
916 fg: cell.fgcolor(),
917 bg: cell.bgcolor(),
918 bold: cell.bold(),
919 dim: cell.dim(),
920 italic: cell.italic(),
921 underline: cell.underline(),
922 inverse: cell.inverse(),
923 }
924 }
925}
926
927fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
928 write!(out, "\x1b[0m")?;
929 if style.bold {
930 write!(out, "\x1b[1m")?;
931 }
932 if style.dim {
933 write!(out, "\x1b[2m")?;
934 }
935 if style.italic {
936 write!(out, "\x1b[3m")?;
937 }
938 if style.underline {
939 write!(out, "\x1b[4m")?;
940 }
941 if style.inverse {
942 write!(out, "\x1b[7m")?;
943 }
944 match style.fg {
945 term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
946 term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
947 _ => {}
948 }
949 match style.bg {
950 term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
951 term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
952 _ => {}
953 }
954 Ok(())
955}
956
957pub fn render_frame(
958 out: &mut dyn Write,
959 screen: &Screen,
960 rows: u16,
961 cols: u16,
962 clear_display: bool,
963) -> io::Result<()> {
964 let mut buf =
965 Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
966 let mut active_style = CellStyle::default();
967
968 buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
970 if clear_display {
971 buf.extend_from_slice(b"\x1b[2J");
972 }
973 buf.extend_from_slice(b"\x1b[?7l");
974
975 for row in 0..rows {
976 write!(buf, "\x1b[{};1H", row + 1)?;
977
978 let mut col: u16 = 0;
979 while col < cols {
980 let cell_opt = screen.cell(row, col);
984 let contents = cell_opt.map_or("", |c| c.contents());
985 let width = if contents.is_empty() {
986 1
987 } else {
988 unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
989 };
990
991 if col + width >= cols {
995 buf.extend_from_slice(b"\x1b[0m\x1b[K");
996 active_style = CellStyle::default();
997 }
998
999 let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
1000 if style != active_style {
1001 apply_sgr(&mut buf, &style)?;
1002 active_style = style;
1003 }
1004
1005 if contents.is_empty() {
1006 buf.push(b' ');
1007 } else {
1008 buf.extend_from_slice(contents.as_bytes());
1009 }
1010
1011 col += width;
1012 }
1013 }
1014
1015 buf.extend_from_slice(b"\x1b[?7h");
1016 buf.extend_from_slice(b"\x1b[0m");
1017 let (cur_row, cur_col) = screen.cursor_position();
1018 write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
1019 if screen.hide_cursor() {
1020 buf.extend_from_slice(b"\x1b[?25l");
1021 } else {
1022 buf.extend_from_slice(b"\x1b[?25h");
1023 }
1024 buf.extend_from_slice(b"\x1b[?2026l");
1026
1027 out.write_all(&buf)?;
1028 out.flush()
1029}
1030
1031#[allow(clippy::unwrap_used)]
1032#[cfg(test)]
1033mod tests {
1034 use super::*;
1035 use std::sync::{Arc, Mutex};
1036
1037 use term_wm_events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
1038
1039 #[test]
1040 fn should_block_nesting_truth_table() {
1041 assert!(
1043 should_block_nesting(
1044 Some("term-wm/prod/alice/gateway"),
1045 "term-wm/prod/alice/gateway",
1046 false
1047 ),
1048 "same gateway, no override -> block"
1049 );
1050 assert!(
1052 !should_block_nesting(
1053 Some("term-wm/prod/alice/gateway"),
1054 "term-wm/dev/alice/gateway",
1055 false
1056 ),
1057 "different gateway -> proceed"
1058 );
1059 assert!(
1061 !should_block_nesting(
1062 Some("term-wm/prod/alice/gateway"),
1063 "term-wm/prod/alice/gateway",
1064 true
1065 ),
1066 "same gateway, allow -> proceed"
1067 );
1068 assert!(
1070 !should_block_nesting(None, "any-gateway", false),
1071 "no gateway set -> proceed"
1072 );
1073 assert!(
1075 !should_block_nesting(None, "any-gateway", true),
1076 "no gateway, allow -> proceed"
1077 );
1078 }
1079
1080 #[test]
1081 fn nested_session_fatal_error_brands_with_app_name() {
1082 let err = nested_session_fatal_error("my-app");
1083 let msg = err.to_string();
1084 assert!(msg.contains("my-app"), "error must contain app name: {msg}");
1085 assert!(
1086 msg.contains("--allow-nested"),
1087 "error must recommend --allow-nested: {msg}"
1088 );
1089 assert!(
1090 msg.starts_with("FATAL:"),
1091 "error must start with FATAL: {msg}"
1092 );
1093 }
1094
1095 #[test]
1096 fn is_nested_session_fatal_detects_fatal_errors() {
1097 let fatal = nested_session_fatal_error("term-wm");
1098 assert!(is_nested_session_fatal(&fatal), "must detect fatal error");
1099
1100 let other = io::Error::other("some other error");
1101 assert!(
1102 !is_nested_session_fatal(&other),
1103 "must not false-positive on other errors"
1104 );
1105
1106 let empty = io::Error::other("");
1107 assert!(
1108 !is_nested_session_fatal(&empty),
1109 "must not false-positive on empty message"
1110 );
1111 }
1112
1113 struct TestWriter {
1114 buf: Arc<Mutex<Vec<u8>>>,
1115 }
1116
1117 impl TestWriter {
1118 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
1119 let buf = Arc::new(Mutex::new(Vec::new()));
1120 (Self { buf: buf.clone() }, buf)
1121 }
1122 }
1123
1124 impl Write for TestWriter {
1125 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1126 self.buf.lock().unwrap().extend_from_slice(buf);
1127 Ok(buf.len())
1128 }
1129 fn flush(&mut self) -> io::Result<()> {
1130 Ok(())
1131 }
1132 }
1133
1134 #[test]
1139 fn init_terminal_writes_bracketed_paste_enable() {
1140 let (writer, buf) = TestWriter::new();
1141 let _guard = init_terminal(writer).expect("init_terminal");
1142 let bytes = buf.lock().unwrap();
1143 assert!(
1144 bytes
1145 .windows(b"\x1b[?2004h".len())
1146 .any(|w| w == b"\x1b[?2004h")
1147 );
1148 }
1149
1150 #[test]
1155 fn init_terminal_writes_mouse_enable_ansi() {
1156 let (writer, buf) = TestWriter::new();
1157 let _guard = init_terminal(writer).expect("init_terminal");
1158 let bytes = buf.lock().unwrap();
1159 assert!(
1160 bytes
1161 .windows(b"\x1b[?1000h".len())
1162 .any(|w| w == b"\x1b[?1000h"),
1163 "init_terminal must emit the mouse-enable ANSI so a host routes mouse input"
1164 );
1165 }
1166
1167 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1173 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1174 LOCK.lock().unwrap_or_else(|e| e.into_inner())
1175 }
1176
1177 #[test]
1178 fn client_ssh_ip_from_ssh_client() {
1179 let _guard = env_lock();
1180 unsafe {
1181 std::env::set_var("SSH_CLIENT", "192.168.1.50 54321 22");
1182 std::env::remove_var("SSH_CONNECTION");
1183 }
1184 assert_eq!(client_ssh_ip().as_deref(), Some("192.168.1.50"));
1185 unsafe {
1186 std::env::remove_var("SSH_CLIENT");
1187 }
1188 }
1189
1190 #[test]
1191 fn client_ssh_ip_from_ssh_connection_fallback() {
1192 let _guard = env_lock();
1193 unsafe {
1194 std::env::remove_var("SSH_CLIENT");
1195 std::env::set_var("SSH_CONNECTION", "10.0.0.7 48000 10.0.0.1 22");
1196 }
1197 assert_eq!(client_ssh_ip().as_deref(), Some("10.0.0.7"));
1198 unsafe {
1199 std::env::remove_var("SSH_CONNECTION");
1200 }
1201 }
1202
1203 #[test]
1204 fn client_ssh_ip_ssh_client_wins_over_connection() {
1205 let _guard = env_lock();
1206 unsafe {
1207 std::env::set_var("SSH_CLIENT", "1.2.3.4 1000 22");
1208 std::env::set_var("SSH_CONNECTION", "9.9.9.9 2000 1.1.1.1 22");
1209 }
1210 assert_eq!(client_ssh_ip().as_deref(), Some("1.2.3.4"));
1211 unsafe {
1212 std::env::remove_var("SSH_CLIENT");
1213 std::env::remove_var("SSH_CONNECTION");
1214 }
1215 }
1216
1217 #[test]
1218 fn client_ssh_ip_none_when_local() {
1219 let _guard = env_lock();
1220 unsafe {
1221 std::env::remove_var("SSH_CLIENT");
1222 std::env::remove_var("SSH_CONNECTION");
1223 }
1224 assert_eq!(client_ssh_ip(), None);
1225 }
1226
1227 #[test]
1228 fn client_version_matches_package() {
1229 assert_eq!(client_version(), env!("CARGO_PKG_VERSION"));
1230 }
1231
1232 #[test]
1233 fn client_user_non_empty() {
1234 assert!(!client_user().is_empty(), "client user must resolve");
1235 }
1236
1237 #[test]
1238 #[cfg(windows)]
1239 fn client_user_prefers_username_env_when_set() {
1240 let _guard = env_lock();
1241 unsafe {
1242 std::env::set_var("USERNAME", "win-test-user");
1243 }
1244 assert_eq!(client_user(), "win-test-user");
1245 unsafe {
1246 std::env::remove_var("USERNAME");
1247 }
1248 }
1249
1250 #[test]
1251 #[cfg(windows)]
1252 fn client_user_falls_back_to_getusername_when_env_absent() {
1253 let _guard = env_lock();
1254 unsafe {
1255 std::env::remove_var("USERNAME");
1256 }
1257 assert!(
1260 !client_user().is_empty(),
1261 "GetUserNameW fallback must resolve a user"
1262 );
1263 }
1264
1265 #[test]
1268 fn terminal_guard_teardown_writes_bracketed_paste_disable() {
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[?2004l".len())
1279 .any(|w| w == b"\x1b[?2004l")
1280 );
1281 }
1282
1283 #[test]
1286 fn terminal_guard_teardown_writes_mouse_disable_ansi() {
1287 let (writer, buf) = TestWriter::new();
1288 {
1289 let _guard = TerminalGuard {
1290 writer: Some(writer),
1291 };
1292 }
1293 let bytes = buf.lock().unwrap();
1294 assert!(
1295 bytes
1296 .windows(b"\x1b[?1000l".len())
1297 .any(|w| w == b"\x1b[?1000l"),
1298 "TerminalGuard teardown must emit the mouse-disable ANSI"
1299 );
1300 }
1301
1302 #[test]
1305 fn init_and_teardown_roundtrip_contains_both_sequences() {
1306 let (writer, buf) = TestWriter::new();
1307 let guard = init_terminal(writer).expect("init_terminal");
1308 drop(guard);
1309 let bytes = buf.lock().unwrap();
1310 assert!(
1311 bytes
1312 .windows(b"\x1b[?2004h".len())
1313 .any(|w| w == b"\x1b[?2004h")
1314 );
1315 assert!(
1316 bytes
1317 .windows(b"\x1b[?2004l".len())
1318 .any(|w| w == b"\x1b[?2004l")
1319 );
1320 }
1321
1322 #[test]
1325 fn test_prev_parser_resize_sync_matches_fresh_parser() {
1326 let mut prev_parser = term_wm_vt100::Parser::new(24, 80, 0);
1327 prev_parser.process(b"initial screen content");
1328
1329 let (new_rows, new_cols) = (40, 120);
1331 let new_formatted_content = {
1332 let mut p = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1333 p.process(b"resized screen content");
1334 p.screen().contents_formatted().to_vec()
1335 };
1336
1337 prev_parser.screen_mut().set_size(new_rows, new_cols);
1339 prev_parser.process(b"\x1bc");
1340 prev_parser.process(&new_formatted_content);
1341
1342 let mut fresh_parser = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1344 fresh_parser.process(&new_formatted_content);
1345
1346 assert_eq!(
1347 prev_parser.screen().contents_formatted(),
1348 fresh_parser.screen().contents_formatted(),
1349 "Reused parser state after set_size + RIS must match fresh parser"
1350 );
1351 }
1352
1353 #[test]
1354 fn render_frame_outputs_correct_cup_and_sgr() {
1355 let mut parser = term_wm_vt100::Parser::new(4, 8, 0);
1356 parser.process(b"\x1b[31mhello\x1b[0m");
1357 let screen = parser.screen();
1358 let mut buf: Vec<u8> = Vec::new();
1359 let (rows, cols) = screen.size();
1360 render_frame(&mut buf, screen, rows, cols, false).unwrap();
1361 let output = String::from_utf8_lossy(&buf);
1362 assert!(output.contains("\x1b[1;1H"));
1364 assert!(output.contains("\x1b[2;1H"));
1365 assert!(output.contains("\x1b[3;1H"));
1366 assert!(output.contains("\x1b[4;1H"));
1367 assert!(output.contains("hello"));
1369 assert!(
1371 output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
1372 "Expected red foreground SGR in output: {output:?}"
1373 );
1374 assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
1376 }
1377
1378 #[test]
1381 fn coalesce_moved_with_moved() {
1382 assert!(is_coalescable_mouse(
1383 &MouseEventKind::Moved,
1384 &KeyModifiers::NONE,
1385 &MouseEventKind::Moved,
1386 &KeyModifiers::NONE,
1387 ));
1388 }
1389
1390 #[test]
1391 fn coalesce_drag_same_button() {
1392 assert!(is_coalescable_mouse(
1393 &MouseEventKind::Drag(MouseButton::Left),
1394 &KeyModifiers::NONE,
1395 &MouseEventKind::Drag(MouseButton::Left),
1396 &KeyModifiers::NONE,
1397 ));
1398 assert!(is_coalescable_mouse(
1399 &MouseEventKind::Drag(MouseButton::Right),
1400 &KeyModifiers {
1401 shift: true,
1402 ..KeyModifiers::NONE
1403 },
1404 &MouseEventKind::Drag(MouseButton::Right),
1405 &KeyModifiers {
1406 shift: true,
1407 ..KeyModifiers::NONE
1408 },
1409 ));
1410 }
1411
1412 #[test]
1413 fn reject_drag_different_button() {
1414 assert!(!is_coalescable_mouse(
1415 &MouseEventKind::Drag(MouseButton::Left),
1416 &KeyModifiers::NONE,
1417 &MouseEventKind::Drag(MouseButton::Right),
1418 &KeyModifiers::NONE,
1419 ));
1420 }
1421
1422 #[test]
1423 fn reject_moved_vs_drag() {
1424 assert!(!is_coalescable_mouse(
1425 &MouseEventKind::Moved,
1426 &KeyModifiers::NONE,
1427 &MouseEventKind::Drag(MouseButton::Left),
1428 &KeyModifiers::NONE,
1429 ));
1430 }
1431
1432 #[test]
1433 fn reject_different_modifiers() {
1434 assert!(!is_coalescable_mouse(
1435 &MouseEventKind::Moved,
1436 &KeyModifiers::NONE,
1437 &MouseEventKind::Moved,
1438 &KeyModifiers {
1439 shift: true,
1440 ..KeyModifiers::NONE
1441 },
1442 ));
1443 assert!(!is_coalescable_mouse(
1444 &MouseEventKind::Drag(MouseButton::Left),
1445 &KeyModifiers {
1446 control: true,
1447 ..KeyModifiers::NONE
1448 },
1449 &MouseEventKind::Drag(MouseButton::Left),
1450 &KeyModifiers::NONE,
1451 ));
1452 }
1453
1454 #[test]
1455 fn reject_discrete_events() {
1456 assert!(!is_coalescable_mouse(
1457 &MouseEventKind::Press(MouseButton::Left),
1458 &KeyModifiers::NONE,
1459 &MouseEventKind::Press(MouseButton::Left),
1460 &KeyModifiers::NONE,
1461 ));
1462 assert!(!is_coalescable_mouse(
1463 &MouseEventKind::Release(MouseButton::Left),
1464 &KeyModifiers::NONE,
1465 &MouseEventKind::Moved,
1466 &KeyModifiers::NONE,
1467 ));
1468 assert!(!is_coalescable_mouse(
1469 &MouseEventKind::Moved,
1470 &KeyModifiers::NONE,
1471 &MouseEventKind::ScrollDown,
1472 &KeyModifiers::NONE,
1473 ));
1474 assert!(!is_coalescable_mouse(
1475 &MouseEventKind::ScrollUp,
1476 &KeyModifiers::NONE,
1477 &MouseEventKind::ScrollUp,
1478 &KeyModifiers::NONE,
1479 ));
1480 }
1481
1482 fn coalesce_through(
1487 events: &[Event],
1488 kind: MouseEventKind,
1489 modifiers: KeyModifiers,
1490 ) -> Option<Event> {
1491 let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
1492 for e in events.iter().cloned() {
1493 tx.send(e).ok();
1494 }
1495 drop(tx);
1496
1497 let mut result = Event::Mouse(MouseEvent {
1498 kind,
1499 modifiers,
1500 column: 0,
1501 row: 0,
1502 });
1503
1504 if let Event::Mouse(ref mut mouse) = result
1505 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
1506 {
1507 while let Ok(next) = rx.try_recv() {
1508 match next {
1509 Event::Mouse(ref next_mouse)
1510 if is_coalescable_mouse(
1511 &mouse.kind,
1512 &mouse.modifiers,
1513 &next_mouse.kind,
1514 &next_mouse.modifiers,
1515 ) =>
1516 {
1517 *mouse = *next_mouse;
1518 }
1519 _other => return Some(result),
1520 }
1521 }
1522 }
1523
1524 Some(result)
1525 }
1526
1527 #[test]
1528 fn coalesce_keeps_latest_moved_position() {
1529 let events = vec![
1530 Event::Mouse(MouseEvent {
1531 kind: MouseEventKind::Moved,
1532 modifiers: KeyModifiers::NONE,
1533 column: 5,
1534 row: 5,
1535 }),
1536 Event::Mouse(MouseEvent {
1537 kind: MouseEventKind::Moved,
1538 modifiers: KeyModifiers::NONE,
1539 column: 10,
1540 row: 10,
1541 }),
1542 Event::Mouse(MouseEvent {
1543 kind: MouseEventKind::Moved,
1544 modifiers: KeyModifiers::NONE,
1545 column: 15,
1546 row: 15,
1547 }),
1548 ];
1549 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1550 let Event::Mouse(m) = result.unwrap() else {
1551 panic!("expected mouse")
1552 };
1553 assert_eq!((m.column, m.row), (15, 15));
1554 }
1555
1556 #[test]
1557 fn coalesce_keeps_latest_drag_position() {
1558 let events = vec![
1559 Event::Mouse(MouseEvent {
1560 kind: MouseEventKind::Drag(MouseButton::Left),
1561 modifiers: KeyModifiers::NONE,
1562 column: 1,
1563 row: 1,
1564 }),
1565 Event::Mouse(MouseEvent {
1566 kind: MouseEventKind::Drag(MouseButton::Left),
1567 modifiers: KeyModifiers::NONE,
1568 column: 2,
1569 row: 2,
1570 }),
1571 ];
1572 let result = coalesce_through(
1573 &events,
1574 MouseEventKind::Drag(MouseButton::Left),
1575 KeyModifiers::NONE,
1576 );
1577 let Event::Mouse(m) = result.unwrap() else {
1578 panic!("expected mouse")
1579 };
1580 assert_eq!((m.column, m.row), (2, 2));
1581 }
1582
1583 #[test]
1584 fn coalesce_stops_at_modifier_change() {
1585 let events = vec![Event::Mouse(MouseEvent {
1586 kind: MouseEventKind::Moved,
1587 modifiers: KeyModifiers {
1588 shift: true,
1589 ..KeyModifiers::NONE
1590 },
1591 column: 99,
1592 row: 99,
1593 })];
1594 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1595 let Event::Mouse(m) = result.unwrap() else {
1596 panic!("expected mouse")
1597 };
1598 assert_eq!((m.column, m.row), (0, 0));
1601 }
1602
1603 #[test]
1604 fn coalesce_stops_at_non_mouse_event() {
1605 let key = Event::Key(KeyEvent {
1606 code: KeyCode::Char('q'),
1607 kind: KeyKind::Press,
1608 modifiers: KeyModifiers::NONE,
1609 });
1610 let events = vec![key.clone()];
1611 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1612 let Event::Mouse(m) = result.unwrap() else {
1613 panic!("expected mouse")
1614 };
1615 assert_eq!((m.column, m.row), (0, 0));
1617 }
1618
1619 #[test]
1620 fn coalesce_stops_at_discrete_mouse_event() {
1621 let events = vec![Event::Mouse(MouseEvent {
1622 kind: MouseEventKind::Press(MouseButton::Left),
1623 modifiers: KeyModifiers::NONE,
1624 column: 10,
1625 row: 10,
1626 })];
1627 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1628 let Event::Mouse(m) = result.unwrap() else {
1629 panic!("expected mouse")
1630 };
1631 assert_eq!((m.column, m.row), (0, 0));
1633 }
1634}
1635
1636#[allow(clippy::unwrap_used)]
1640#[cfg(test)]
1641#[allow(clippy::type_complexity)]
1642mod snapshot_tests {
1643 use super::*;
1644
1645 fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
1648 let rt = tokio::runtime::Builder::new_current_thread()
1649 .build()
1650 .expect("tokio rt");
1651 let (push_tx, push_rx) = crossbeam_channel::bounded(16);
1652 let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
1653 let mut pane = RemotePane::new(
1654 0,
1655 None,
1656 rt.handle().clone(),
1657 cols,
1658 rows,
1659 push_rx,
1660 input_writer,
1661 );
1662 drop(rt); push_tx.send(pty_bytes.to_vec()).ok();
1665 pane.drain_pushes();
1666
1667 let parser = pane.shared_parser();
1668 let parser = parser.lock().unwrap();
1669 let screen = parser.screen();
1670 let (rows, cols) = screen.size();
1671 let mut out = Vec::new();
1672 render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
1673 out
1674 }
1675
1676 fn escape_ansi(bytes: &[u8]) -> String {
1678 let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
1679 for &b in bytes {
1680 match b {
1681 b'\x1b' => out.extend_from_slice(b"\\x1b"),
1682 b'\n' => out.extend_from_slice(b"\\n"),
1683 b'\r' => out.extend_from_slice(b"\\r"),
1684 b'\t' => out.extend_from_slice(b"\\t"),
1685 0x20..=0x7e => out.push(b),
1686 _ => {
1687 out.push(b'\\');
1688 out.push(b'x');
1689 out.extend_from_slice(&hex_byte(b));
1690 }
1691 }
1692 }
1693 unsafe { String::from_utf8_unchecked(out) }
1695 }
1696
1697 fn hex_byte(b: u8) -> [u8; 2] {
1698 #[inline]
1699 fn hex_nibble(n: u8) -> u8 {
1700 let digit = n & 0x0f;
1701 if digit < 10 {
1702 b'0' + digit
1703 } else {
1704 b'a' + digit - 10
1705 }
1706 }
1707 [hex_nibble(b >> 4), hex_nibble(b)]
1708 }
1709
1710 #[test]
1713 fn snapshot_empty_grid() {
1714 let out = render_and_capture(b"", 4, 8, false);
1715 insta::assert_snapshot!("empty_grid", escape_ansi(&out));
1716 }
1717
1718 #[test]
1719 fn snapshot_basic_text() {
1720 let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
1721 insta::assert_snapshot!("basic_text", escape_ansi(&out));
1722 }
1723
1724 #[test]
1725 fn snapshot_colored_text() {
1726 let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
1727 insta::assert_snapshot!("colored_text", escape_ansi(&out));
1728 }
1729
1730 #[test]
1731 fn snapshot_normal_char_at_margin() {
1732 let out = render_and_capture(b"ABCD", 1, 4, false);
1735 insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
1736 }
1737
1738 #[test]
1739 fn snapshot_clear_display() {
1740 let out = render_and_capture(b"", 4, 8, true);
1741 insta::assert_snapshot!("clear_display", escape_ansi(&out));
1742 }
1743
1744 #[test]
1745 fn snapshot_hidden_cursor() {
1746 let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
1747 insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
1748 }
1749
1750 #[test]
1751 fn snapshot_color_across_margin() {
1752 let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
1755 insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
1756 }
1757
1758 #[test]
1759 fn snapshot_multi_row_fill() {
1760 let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
1763 insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
1764 }
1765
1766 #[test]
1767 fn snapshot_wide_char_margin() {
1768 let out = render_and_capture(
1771 b"B\xe3\x81\x82", 1,
1773 3,
1774 false,
1775 );
1776 insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
1777 }
1778
1779 #[test]
1780 fn snapshot_wide_char_middle() {
1781 let out = render_and_capture(
1785 b"A\xe3\x81\x82\xe3\x81\x83", 1,
1787 5,
1788 false,
1789 );
1790 insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1791 }
1792}