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(
112 app_name: &str,
113 active_gateway: Option<&str>,
114 target_socket: &str,
115) -> io::Error {
116 io::Error::other(format!(
117 "FATAL: Attempted to run {app_name} inside an existing session environment on the same gateway.\n\
118 Session inception can cause terminal buffer corruption and accidental gateway stops.\n\n\
119 Active session gateway: {}\n \
120 Requested gateway: {target_socket}\n\n\
121 This is genuinely the SAME gateway as your enclosing session.\nOptions:\n \
122 - Run on a different gateway: set TERM_WM_NAMESPACE=<ns> (namespace root)\n \
123 or pass --gateway <name> (whole path).\n \
124 - Force nested execution: rerun with:\n \
125 {app_name} --allow-nested [args...]",
126 active_gateway.unwrap_or("<unset>")
127 ))
128}
129
130pub fn is_nested_session_fatal(err: &io::Error) -> bool {
132 err.to_string().contains("FATAL: Attempted to run")
133}
134
135fn should_block_nesting(
139 active_gateway: Option<&str>,
140 target_socket: &str,
141 allow_nested: bool,
142) -> bool {
143 if allow_nested {
144 return false;
145 }
146 match active_gateway {
147 Some(active) => active == target_socket,
148 None => false,
149 }
150}
151
152pub fn init_terminal<W: Write>(mut writer: W) -> io::Result<TerminalGuard<W>> {
160 if std::io::stdin().is_terminal() {
161 enable_raw_mode()?;
162 }
163 writer.queue(EnterAlternateScreen)?;
164 writer.queue(Hide)?;
165 writer.queue(EnableBracketedPaste)?;
166 writer.queue(crossterm::event::EnableMouseCapture)?;
167 #[cfg(windows)]
175 term_wm_crossterm_adapter::set_mouse_capture_with(&mut writer, true)?;
176 writer.flush()?;
177 Ok(TerminalGuard {
178 writer: Some(writer),
179 })
180}
181
182pub struct TerminalGuard<W: Write = std::io::Stdout> {
186 writer: Option<W>,
187}
188
189impl<W: Write> Drop for TerminalGuard<W> {
190 fn drop(&mut self) {
191 if let Some(ref mut writer) = self.writer {
192 let _ = writer.queue(crossterm::event::DisableMouseCapture);
193 #[cfg(windows)]
196 let _ = term_wm_crossterm_adapter::set_mouse_capture_with(writer, false);
197 let _ = writer.queue(DisableBracketedPaste);
198 let _ = writer.queue(Show);
199 let _ = writer.queue(LeaveAlternateScreen);
200 if std::io::stdin().is_terminal() {
201 let _ = disable_raw_mode();
202 }
203 let _ = writer.flush();
204 }
205 }
206}
207
208fn convert_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
210 term_wm_crossterm_adapter::try_translate_event(evt)
211}
212
213fn attributed_mouse_is_motion(e: &Event) -> bool {
218 matches!(
219 e,
220 Event::Mouse(m) if matches!(m.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
221 )
222}
223
224fn attributed_mouse_coalescable(a: &Event, b: &Event) -> bool {
227 match (a, b) {
228 (Event::Mouse(am), Event::Mouse(bm)) => {
229 is_coalescable_mouse(&am.kind, &am.modifiers, &bm.kind, &bm.modifiers)
230 }
231 _ => false,
232 }
233}
234
235fn is_coalescable_mouse(
236 a_kind: &MouseEventKind,
237 a_mod: &KeyModifiers,
238 b_kind: &MouseEventKind,
239 b_mod: &KeyModifiers,
240) -> bool {
241 if a_mod != b_mod {
242 return false;
243 }
244 match (a_kind, b_kind) {
245 (MouseEventKind::Moved, MouseEventKind::Moved) => true,
246 (MouseEventKind::Drag(btn1), MouseEventKind::Drag(btn2)) => btn1 == btn2,
247 _ => false,
248 }
249}
250
251fn client_user() -> String {
256 #[cfg(unix)]
257 {
258 unsafe {
259 let pw = libc::getpwuid(libc::getuid());
260 if !pw.is_null() {
261 let name = std::ffi::CStr::from_ptr((*pw).pw_name);
262 if let Ok(s) = name.to_str()
263 && !s.is_empty()
264 {
265 return s.to_string();
266 }
267 }
268 }
269 std::env::var("USER").unwrap_or_default()
270 }
271 #[cfg(windows)]
272 {
273 if let Ok(u) = std::env::var("USERNAME")
274 && !u.is_empty()
275 {
276 return u;
277 }
278 windows_username().unwrap_or_default()
279 }
280 #[cfg(not(any(unix, windows)))]
281 {
282 String::new()
283 }
284}
285
286#[cfg(windows)]
289fn windows_username() -> Option<String> {
290 use std::os::windows::ffi::OsStringExt;
291 use windows_sys::Win32::System::WindowsProgramming::GetUserNameW;
292 let mut buf = [0u16; 256];
293 let mut len = buf.len() as u32;
294 let ok = unsafe { GetUserNameW(buf.as_mut_ptr(), &mut len) };
295 if ok == 0 {
296 return None;
297 }
298 let s = std::ffi::OsString::from_wide(&buf[..len as usize])
299 .to_string_lossy()
300 .into_owned();
301 if s.is_empty() { None } else { Some(s) }
302}
303
304fn client_version() -> String {
307 env!("CARGO_PKG_VERSION").to_string()
308}
309
310fn client_ssh_ip() -> Option<String> {
314 for var in ["SSH_CLIENT", "SSH_CONNECTION"] {
315 if let Ok(v) = std::env::var(var) {
316 let ip = v.split_whitespace().next()?;
317 if !ip.is_empty() {
318 return Some(ip.to_string());
319 }
320 }
321 }
322 None
323}
324
325fn client_ssh_port() -> Option<u16> {
329 for var in ["SSH_CLIENT", "SSH_CONNECTION"] {
330 if let Ok(v) = std::env::var(var)
331 && let Some(port_str) = v.split_whitespace().nth(1)
332 && let Ok(port) = port_str.parse::<u16>()
333 {
334 return Some(port);
335 }
336 }
337 None
338}
339
340pub fn run_session(
351 socket_path: &str,
352 channel: &str,
353 cmd: &[String],
354 allow_nested: bool,
355 app_name: &str,
356) -> io::Result<Option<String>> {
357 let host_gateway =
363 std::env::var(term_session_muxio_service_definitions::SESSION_GATEWAY_ENV_VAR).ok();
364 if should_block_nesting(host_gateway.as_deref(), socket_path, allow_nested) {
365 return Err(nested_session_fatal_error(
366 app_name,
367 host_gateway.as_deref(),
368 socket_path,
369 ));
370 }
371
372 #[cfg(windows)]
377 disable_quick_edit();
378
379 let rt =
380 tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
381
382 let client: Arc<RpcIpcClient> = rt
384 .block_on(RpcIpcClient::new(socket_path))
385 .map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, format!("{e:?}")))?;
386
387 let abi_fault = |e: &dyn std::fmt::Display| -> io::Error {
391 io::Error::other(format!(
392 "FATAL: Protocol ABI mismatch. A legacy daemon may be occupying the IPC socket. Manually terminate the daemon process before continuing. (cause: {e})"
393 ))
394 };
395
396 let server_cols = Arc::new(AtomicU16::new(0));
400 let server_rows = Arc::new(AtomicU16::new(0));
401 let resize_pending = Arc::new(AtomicBool::new(false));
402
403 {
404 let cols_ref = Arc::clone(&server_cols);
405 let rows_ref = Arc::clone(&server_rows);
406 let pending_ref = Arc::clone(&resize_pending);
407 rt.block_on(client.get_endpoint().register_prebuffered(
408 OnPtyResized::METHOD_ID,
409 move |payload, _ctx| {
410 let cols_ref = Arc::clone(&cols_ref);
411 let rows_ref = Arc::clone(&rows_ref);
412 let pending_ref = Arc::clone(&pending_ref);
413 async move {
414 let (cols, rows) = OnPtyResized::decode_request(&payload)
415 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
416 cols_ref.store(cols, Ordering::Relaxed);
417 rows_ref.store(rows, Ordering::Relaxed);
418 pending_ref.store(true, Ordering::Relaxed);
419 OnPtyResized::encode_response(())
420 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
421 }
422 },
423 ))
424 .map_err(|e| io::Error::other(format!("register OnPtyResized: {e:?}")))?;
425 }
426
427 let rebind_target: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
430
431 {
432 let target_ref = Arc::clone(&rebind_target);
433 rt.block_on(client.get_endpoint().register_prebuffered(
434 OnWorkspaceRebind::METHOD_ID,
435 move |payload, _ctx| {
436 let target_ref = Arc::clone(&target_ref);
437 async move {
438 let req = OnWorkspaceRebind::decode_request(&payload)
439 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
440 *target_ref.lock().unwrap_or_else(|err| err.into_inner()) = Some(req.target);
441 OnWorkspaceRebind::encode_response(())
442 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
443 }
444 },
445 ))
446 .map_err(|e| io::Error::other(format!("register OnWorkspaceRebind: {e:?}")))?;
447 }
448
449 let (push_tx, push_rx) = crossbeam_channel::bounded::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
453 let (clip_tx, clip_rx) = crossbeam_channel::bounded::<String>(CLIPBOARD_CHANNEL_CAPACITY);
454
455 let (term_cols, term_rows) = match crossterm::terminal::size() {
462 Ok((c, r)) => (c.max(MIN_TERM_COLS), r.max(MIN_TERM_ROWS)),
463 Err(_) => (FALLBACK_TERM_COLS, FALLBACK_TERM_ROWS),
464 };
465 let hostname = hostname::get()
466 .map(|h| h.to_string_lossy().into_owned())
467 .unwrap_or_else(|_| "unknown".to_string());
468
469 let (actual_cols, actual_rows) = rt.block_on(async {
470 let conn_id = Attach::call(
473 &*client,
474 AttachRequest {
475 channel: channel.to_string(),
476 hostname,
477 pid: std::process::id() as u64,
478 user: client_user(),
479 version: client_version(),
480 ssh_ip: client_ssh_ip(),
481 ssh_port: client_ssh_port(),
482 },
483 )
484 .await
485 .map_err(|e| abi_fault(&e))?;
486 let cmd = if cmd.is_empty() {
488 None
489 } else {
490 Some(cmd.to_vec())
491 };
492 let launch_cwd = std::env::current_dir().ok().map(path_wire::encode_path);
498 let SpawnResponse {
499 id: _session_id,
500 cols: actual_cols,
501 rows: actual_rows,
502 ..
503 } = Spawn::call(
504 &*client,
505 SpawnRequest {
506 cmd,
507 cols: term_cols,
508 rows: term_rows,
509 cwd: launch_cwd,
510 },
511 )
512 .await
513 .map_err(|e| abi_fault(&e))?;
514 let _ = conn_id;
515 Ok::<(u16, u16), io::Error>((actual_cols, actual_rows))
516 })?;
517
518 let writer = rt.block_on(async {
520 let (_, mut reader) = client
523 .open_channel(SUBSCRIBE_OUTPUT_METHOD_ID, 0)
524 .await
525 .map_err(|e| io::Error::other(format!("subscribe: {e:?}")))?;
526
527 rt.spawn(async move {
531 let mut osc52 = Osc52Extractor::new();
532 let mut prev_tail: [u8; PREV_TAIL_LEN] = [0; PREV_TAIL_LEN];
533
534 while let Some(chunk) = reader.recv().await {
535 if let Ok(mut data) = chunk {
536 if let Some(text) = osc52.push(&data, &prev_tail) {
537 let _ = clip_tx.try_send(text);
538 }
539
540 let n = data.len();
541 if n >= PREV_TAIL_LEN {
542 prev_tail.copy_from_slice(&data[n - PREV_TAIL_LEN..n]);
543 } else if n > 0 {
544 prev_tail.rotate_left(n);
545 prev_tail[PREV_TAIL_LEN - n..].copy_from_slice(&data[..n]);
546 }
547
548 while let Err(crossbeam_channel::TrySendError::Full(pending)) =
551 push_tx.try_send(data)
552 {
553 data = pending;
554 tokio::time::sleep(Duration::from_millis(BACKPRESSURE_SLEEP_MS)).await;
555 }
556 } else {
557 break;
558 }
559 }
560 if let Some(text) = osc52.finish() {
563 let _ = clip_tx.try_send(text);
564 }
565 });
566
567 let (writer, _) = client
570 .open_channel(STREAM_INPUT_METHOD_ID, 0)
571 .await
572 .map_err(|e| io::Error::other(format!("stream input: {e:?}")))?;
573
574 Ok::<_, io::Error>(writer)
575 })?;
576
577 let input_writer = Box::new(move |data: &[u8]| -> io::Result<()> {
578 writer
579 .send(data.to_vec())
580 .map_err(|e| io::Error::other(e.to_string()))?;
581 Ok(())
582 });
583
584 let mut pane = RemotePane::new(
585 1u64,
586 Some(client.clone()),
587 rt.handle().clone(),
588 term_cols,
589 term_rows,
590 push_rx.clone(),
591 input_writer,
592 );
593
594 let (attributed_tx, mut attributed_rx) = tokio::sync::mpsc::channel::<
597 term_session_muxio_service_definitions::SendAttributedInputRequest,
598 >(1024);
599 {
600 let client_clone = client.clone();
601 rt.spawn(async move {
602 use muxio_rpc_service_caller::prebuffered::RpcCallPrebuffered;
603 use term_session_muxio_service_definitions::SendAttributedInput;
604
605 let mut pending_req = None;
606 loop {
607 let mut req = match pending_req.take() {
608 Some(r) => r,
609 None => match attributed_rx.recv().await {
610 Some(r) => r,
611 None => break,
612 },
613 };
614
615 while attributed_mouse_is_motion(&req.event) {
620 match attributed_rx.try_recv() {
621 Ok(next_req)
622 if attributed_mouse_coalescable(&req.event, &next_req.event) =>
623 {
624 req = next_req;
625 }
626 Ok(next_req) => {
627 pending_req = Some(next_req);
628 break;
629 }
630 Err(_) => break,
631 }
632 }
633 let _ = SendAttributedInput::call(&*client_clone, req).await;
634 }
635 });
636 }
637
638 for _ in 0..INITIAL_WAIT_ITERS {
640 pane.drain_pushes();
641 let parser = pane.shared_parser();
642 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
643 if !parser.screen().contents_formatted().is_empty() {
644 break;
645 }
646 drop(parser);
647 std::thread::sleep(Duration::from_millis(INITIAL_WAIT_SLEEP_MS));
648 }
649
650 {
652 let parser = pane.shared_parser();
653 let mut parser_lk = parser.lock().unwrap_or_else(|e| e.into_inner());
654 let (cur_rows, cur_cols) = parser_lk.screen().size();
655 if actual_cols != cur_cols || actual_rows != cur_rows {
656 parser_lk.screen_mut().set_size(actual_rows, actual_cols);
657 }
658 drop(parser_lk);
659 }
660
661 #[cfg(unix)]
672 let _ = redirect_fd_to_tracing(libc::STDERR_FILENO, true);
673 let _guard = init_terminal(stdout())?;
674 let mut out = stdout();
675
676 let mut clipboard = Clipboard::new();
677 let sigint = install_sigint_handler()?;
678
679 let (input_tx, input_rx) = crossbeam_channel::bounded::<Event>(INPUT_CHANNEL_CAPACITY);
681
682 std::thread::Builder::new()
686 .name("crossterm-input".into())
687 .spawn(move || {
688 loop {
689 match crossterm::event::poll(Duration::from_millis(INPUT_POLL_MS)) {
690 Ok(true) => {
691 if let Ok(crossterm_evt) = crossterm::event::read()
692 && let Some(e) = convert_crossterm_event(crossterm_evt)
693 && input_tx.send(e).is_err()
694 {
695 break;
696 }
697 }
698 Ok(false) => continue,
699 Err(_) => break,
700 }
701 }
702 })
703 .map_err(|e| io::Error::other(format!("spawn input thread: {e}")))?;
704
705 {
707 let parser = pane.shared_parser();
708 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
709 let screen = parser.screen();
710 let (rows, cols) = screen.size();
711 render_frame(&mut out, screen, rows, cols, false)?;
712 }
713
714 let mut pending_input: Option<Event> = None;
715 loop {
716 let mut force_render = false;
717 let mut clear_display = false;
718
719 let apply_pending_resize = |shared_parser: &Arc<Mutex<Parser>>| -> bool {
722 if resize_pending.swap(false, Ordering::Relaxed) {
723 let cols = server_cols.load(Ordering::Relaxed);
724 let rows = server_rows.load(Ordering::Relaxed);
725 if cols > 0 && rows > 0 {
726 let mut parser_lk = shared_parser.lock().unwrap_or_else(|e| e.into_inner());
727 let (cur_rows, cur_cols) = parser_lk.screen().size();
728 if cur_cols != cols || cur_rows != rows {
729 parser_lk.screen_mut().set_size(rows, cols);
730 return true;
731 }
732 }
733 }
734 false
735 };
736
737 let resized = apply_pending_resize(&pane.shared_parser());
739 force_render |= resized;
740 clear_display |= resized;
741
742 if let Some(target) = rebind_target
746 .lock()
747 .unwrap_or_else(|err| err.into_inner())
748 .take()
749 {
750 return Ok(Some(target));
751 }
752
753 let input_event = if let Some(evt) = pending_input.take() {
756 Some(evt)
757 } else {
758 crossbeam_channel::select! {
759 recv(input_rx) -> msg => {
760 match msg {
761 Ok(evt) => Some(evt),
762 Err(_) => return Err(io::Error::other("input thread died")),
763 }
764 }
765 recv(push_rx) -> msg => {
766 match msg {
767 Ok(data) => {
768 let resized = apply_pending_resize(&pane.shared_parser());
773 force_render |= resized;
774 clear_display |= resized;
775
776 let parser = pane.shared_parser();
778 let mut parser = parser.lock().unwrap_or_else(|e| e.into_inner());
779 parser.process(&data);
780 None
781 }
782 Err(_) => {
783 None
786 }
787 }
788 }
789 recv(crossbeam_channel::after(Duration::from_millis(100))) -> _ => {
790 force_render = true;
793 None
794 }
795 }
796 };
797
798 let has_new_data = pane.drain_pushes() || input_event.is_none();
800
801 while let Ok(text) = clip_rx.try_recv() {
803 clipboard.set(&text);
804 }
805
806 if sigint.received() {
808 sigint.ack();
809 let _ = pane.write_bytes(&[0x03]);
810 }
811
812 if let Some(mut evt) = input_event {
814 if let Event::Mouse(ref mut mouse) = evt
819 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
820 {
821 while let Ok(next_evt) = input_rx.try_recv() {
822 match next_evt {
823 Event::Mouse(ref next_mouse)
824 if is_coalescable_mouse(
825 &mouse.kind,
826 &mouse.modifiers,
827 &next_mouse.kind,
828 &next_mouse.modifiers,
829 ) =>
830 {
831 *mouse = *next_mouse;
832 }
833 other => {
834 pending_input = Some(other);
835 break;
836 }
837 }
838 }
839 }
840
841 {
845 use term_session_muxio_service_definitions::SendAttributedInputRequest;
846 let _ = attributed_tx.try_send(SendAttributedInputRequest {
847 channel: channel.to_string(),
848 event: evt.clone(),
849 });
850 }
851
852 match evt {
853 Event::Key(ref key)
854 if key.kind == KeyKind::Press || key.kind == KeyKind::Repeat =>
855 {
856 let bytes = key_to_bytes(key, false);
857 if !bytes.is_empty() {
858 let _ = pane.write_bytes(&bytes);
859 }
860 }
861 Event::Mouse(ref mouse) => {
862 let mouse_active = {
863 let parser = pane.shared_parser();
864 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
865 parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
866 };
867 if mouse_active {
868 let bytes = mouse_event_to_bytes(mouse, MouseProtocolEncoding::Sgr);
869 if !bytes.is_empty() {
870 let _ = pane.write_bytes(&bytes);
871 }
872 }
873 }
874 Event::Resize(w, h) => {
875 let size = PtySize {
876 rows: h,
877 cols: w,
878 pixel_width: 0,
879 pixel_height: 0,
880 };
881 if let Err(err) = pane.resize(size) {
882 tracing::warn!(error = %err, "resize request failed on PTY pane");
883 }
884 force_render = true;
885 clear_display = true;
886 }
887 Event::Paste(text) => {
888 let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
889 wrapped.extend_from_slice(b"\x1b[200~");
890 wrapped.extend_from_slice(text.as_bytes());
891 wrapped.extend_from_slice(b"\x1b[201~");
892 let _ = pane.write_bytes(&wrapped);
893 }
894 _ => {}
895 }
896 }
897
898 if !client.is_connected() {
900 return Err(io::Error::other("connection to session server lost"));
901 }
902
903 if has_new_data || force_render {
905 let parser = pane.shared_parser();
906 let parser = parser.lock().unwrap_or_else(|e| e.into_inner());
907 let screen = parser.screen();
908 let (rows, cols) = screen.size();
909 render_frame(&mut out, screen, rows, cols, clear_display)?;
910 }
911
912 if pane.has_exited() {
914 return Ok(None);
915 }
916 }
917}
918
919#[derive(Default, PartialEq, Clone, Copy)]
920struct CellStyle {
921 fg: term_wm_vt100::Color,
922 bg: term_wm_vt100::Color,
923 bold: bool,
924 dim: bool,
925 italic: bool,
926 underline: bool,
927 inverse: bool,
928}
929
930impl CellStyle {
931 fn from_cell(cell: &term_wm_vt100::Cell) -> Self {
932 Self {
933 fg: cell.fgcolor(),
934 bg: cell.bgcolor(),
935 bold: cell.bold(),
936 dim: cell.dim(),
937 italic: cell.italic(),
938 underline: cell.underline(),
939 inverse: cell.inverse(),
940 }
941 }
942}
943
944fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
945 write!(out, "\x1b[0m")?;
946 if style.bold {
947 write!(out, "\x1b[1m")?;
948 }
949 if style.dim {
950 write!(out, "\x1b[2m")?;
951 }
952 if style.italic {
953 write!(out, "\x1b[3m")?;
954 }
955 if style.underline {
956 write!(out, "\x1b[4m")?;
957 }
958 if style.inverse {
959 write!(out, "\x1b[7m")?;
960 }
961 match style.fg {
962 term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
963 term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
964 _ => {}
965 }
966 match style.bg {
967 term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
968 term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
969 _ => {}
970 }
971 Ok(())
972}
973
974pub fn render_frame(
975 out: &mut dyn Write,
976 screen: &Screen,
977 rows: u16,
978 cols: u16,
979 clear_display: bool,
980) -> io::Result<()> {
981 let mut buf =
982 Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
983 let mut active_style = CellStyle::default();
984
985 buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
987 if clear_display {
988 buf.extend_from_slice(b"\x1b[2J");
989 }
990 buf.extend_from_slice(b"\x1b[?7l");
991
992 for row in 0..rows {
993 write!(buf, "\x1b[{};1H", row + 1)?;
994
995 let mut col: u16 = 0;
996 while col < cols {
997 let cell_opt = screen.cell(row, col);
1001 let contents = cell_opt.map_or("", |c| c.contents());
1002 let width = if contents.is_empty() {
1003 1
1004 } else {
1005 unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
1006 };
1007
1008 if col + width >= cols {
1012 buf.extend_from_slice(b"\x1b[0m\x1b[K");
1013 active_style = CellStyle::default();
1014 }
1015
1016 let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
1017 if style != active_style {
1018 apply_sgr(&mut buf, &style)?;
1019 active_style = style;
1020 }
1021
1022 if contents.is_empty() {
1023 buf.push(b' ');
1024 } else {
1025 buf.extend_from_slice(contents.as_bytes());
1026 }
1027
1028 col += width;
1029 }
1030 }
1031
1032 buf.extend_from_slice(b"\x1b[?7h");
1033 buf.extend_from_slice(b"\x1b[0m");
1034 let (cur_row, cur_col) = screen.cursor_position();
1035 write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
1036 if screen.hide_cursor() {
1037 buf.extend_from_slice(b"\x1b[?25l");
1038 } else {
1039 buf.extend_from_slice(b"\x1b[?25h");
1040 }
1041 buf.extend_from_slice(b"\x1b[?2026l");
1043
1044 out.write_all(&buf)?;
1045 out.flush()
1046}
1047
1048#[allow(clippy::unwrap_used)]
1049#[cfg(test)]
1050mod tests {
1051 use super::*;
1052 use std::sync::{Arc, Mutex};
1053
1054 use term_wm_events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
1055
1056 #[test]
1057 fn should_block_nesting_truth_table() {
1058 assert!(
1060 should_block_nesting(
1061 Some("term-wm/prod/alice/gateway"),
1062 "term-wm/prod/alice/gateway",
1063 false
1064 ),
1065 "same gateway, no override -> block"
1066 );
1067 assert!(
1069 !should_block_nesting(
1070 Some("term-wm/prod/alice/gateway"),
1071 "term-wm/dev/alice/gateway",
1072 false
1073 ),
1074 "different gateway -> proceed"
1075 );
1076 assert!(
1078 !should_block_nesting(
1079 Some("term-wm/prod/alice/gateway"),
1080 "term-wm/prod/alice/gateway",
1081 true
1082 ),
1083 "same gateway, allow -> proceed"
1084 );
1085 assert!(
1087 !should_block_nesting(None, "any-gateway", false),
1088 "no gateway set -> proceed"
1089 );
1090 assert!(
1092 !should_block_nesting(None, "any-gateway", true),
1093 "no gateway, allow -> proceed"
1094 );
1095 }
1096
1097 #[test]
1098 fn nested_session_fatal_error_brands_with_app_name() {
1099 let err = nested_session_fatal_error("my-app", Some("host/gateway"), "target/gateway");
1100 let msg = err.to_string();
1101 assert!(msg.contains("my-app"), "error must contain app name: {msg}");
1102 assert!(
1103 msg.contains("--allow-nested"),
1104 "error must recommend --allow-nested: {msg}"
1105 );
1106 assert!(
1107 msg.starts_with("FATAL:"),
1108 "error must start with FATAL: {msg}"
1109 );
1110 }
1111
1112 #[test]
1113 fn nested_session_fatal_error_names_both_endpoints() {
1114 let err = nested_session_fatal_error(
1115 "term-wm",
1116 Some("term-wm/alice/gateway"),
1117 "term-wm-dev/alice/gateway",
1118 );
1119 let msg = err.to_string();
1120 assert!(
1121 msg.contains("Active session gateway: term-wm/alice/gateway"),
1122 "must name the active endpoint: {msg}"
1123 );
1124 assert!(
1125 msg.contains("Requested gateway: term-wm-dev/alice/gateway"),
1126 "must name the requested endpoint: {msg}"
1127 );
1128 let unset = nested_session_fatal_error("term-wm", None, "x/gateway").to_string();
1130 assert!(
1131 unset.contains("Active session gateway: <unset>"),
1132 "unset marker must render as placeholder: {unset}"
1133 );
1134 }
1135
1136 #[test]
1137 fn is_nested_session_fatal_detects_fatal_errors() {
1138 let fatal = nested_session_fatal_error("term-wm", Some("h/g"), "t/g");
1139 assert!(is_nested_session_fatal(&fatal), "must detect fatal error");
1140
1141 let other = io::Error::other("some other error");
1142 assert!(
1143 !is_nested_session_fatal(&other),
1144 "must not false-positive on other errors"
1145 );
1146
1147 let empty = io::Error::other("");
1148 assert!(
1149 !is_nested_session_fatal(&empty),
1150 "must not false-positive on empty message"
1151 );
1152 }
1153
1154 struct TestWriter {
1155 buf: Arc<Mutex<Vec<u8>>>,
1156 }
1157
1158 impl TestWriter {
1159 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
1160 let buf = Arc::new(Mutex::new(Vec::new()));
1161 (Self { buf: buf.clone() }, buf)
1162 }
1163 }
1164
1165 impl Write for TestWriter {
1166 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1167 self.buf.lock().unwrap().extend_from_slice(buf);
1168 Ok(buf.len())
1169 }
1170 fn flush(&mut self) -> io::Result<()> {
1171 Ok(())
1172 }
1173 }
1174
1175 #[test]
1180 fn init_terminal_writes_bracketed_paste_enable() {
1181 let (writer, buf) = TestWriter::new();
1182 let _guard = init_terminal(writer).expect("init_terminal");
1183 let bytes = buf.lock().unwrap();
1184 assert!(
1185 bytes
1186 .windows(b"\x1b[?2004h".len())
1187 .any(|w| w == b"\x1b[?2004h")
1188 );
1189 }
1190
1191 #[test]
1196 fn init_terminal_writes_mouse_enable_ansi() {
1197 let (writer, buf) = TestWriter::new();
1198 let _guard = init_terminal(writer).expect("init_terminal");
1199 let bytes = buf.lock().unwrap();
1200 assert!(
1201 bytes
1202 .windows(b"\x1b[?1000h".len())
1203 .any(|w| w == b"\x1b[?1000h"),
1204 "init_terminal must emit the mouse-enable ANSI so a host routes mouse input"
1205 );
1206 }
1207
1208 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1214 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1215 LOCK.lock().unwrap_or_else(|e| e.into_inner())
1216 }
1217
1218 #[test]
1219 fn client_ssh_ip_from_ssh_client() {
1220 let _guard = env_lock();
1221 unsafe {
1222 std::env::set_var("SSH_CLIENT", "192.168.1.50 54321 22");
1223 std::env::remove_var("SSH_CONNECTION");
1224 }
1225 assert_eq!(client_ssh_ip().as_deref(), Some("192.168.1.50"));
1226 unsafe {
1227 std::env::remove_var("SSH_CLIENT");
1228 }
1229 }
1230
1231 #[test]
1232 fn client_ssh_ip_from_ssh_connection_fallback() {
1233 let _guard = env_lock();
1234 unsafe {
1235 std::env::remove_var("SSH_CLIENT");
1236 std::env::set_var("SSH_CONNECTION", "10.0.0.7 48000 10.0.0.1 22");
1237 }
1238 assert_eq!(client_ssh_ip().as_deref(), Some("10.0.0.7"));
1239 unsafe {
1240 std::env::remove_var("SSH_CONNECTION");
1241 }
1242 }
1243
1244 #[test]
1245 fn client_ssh_ip_ssh_client_wins_over_connection() {
1246 let _guard = env_lock();
1247 unsafe {
1248 std::env::set_var("SSH_CLIENT", "1.2.3.4 1000 22");
1249 std::env::set_var("SSH_CONNECTION", "9.9.9.9 2000 1.1.1.1 22");
1250 }
1251 assert_eq!(client_ssh_ip().as_deref(), Some("1.2.3.4"));
1252 unsafe {
1253 std::env::remove_var("SSH_CLIENT");
1254 std::env::remove_var("SSH_CONNECTION");
1255 }
1256 }
1257
1258 #[test]
1259 fn client_ssh_ip_none_when_local() {
1260 let _guard = env_lock();
1261 unsafe {
1262 std::env::remove_var("SSH_CLIENT");
1263 std::env::remove_var("SSH_CONNECTION");
1264 }
1265 assert_eq!(client_ssh_ip(), None);
1266 }
1267
1268 #[test]
1269 fn client_version_matches_package() {
1270 assert_eq!(client_version(), env!("CARGO_PKG_VERSION"));
1271 }
1272
1273 #[test]
1274 fn client_user_non_empty() {
1275 assert!(!client_user().is_empty(), "client user must resolve");
1276 }
1277
1278 #[test]
1279 #[cfg(windows)]
1280 fn client_user_prefers_username_env_when_set() {
1281 let _guard = env_lock();
1282 unsafe {
1283 std::env::set_var("USERNAME", "win-test-user");
1284 }
1285 assert_eq!(client_user(), "win-test-user");
1286 unsafe {
1287 std::env::remove_var("USERNAME");
1288 }
1289 }
1290
1291 #[test]
1292 #[cfg(windows)]
1293 fn client_user_falls_back_to_getusername_when_env_absent() {
1294 let _guard = env_lock();
1295 unsafe {
1296 std::env::remove_var("USERNAME");
1297 }
1298 assert!(
1301 !client_user().is_empty(),
1302 "GetUserNameW fallback must resolve a user"
1303 );
1304 }
1305
1306 #[test]
1309 fn terminal_guard_teardown_writes_bracketed_paste_disable() {
1310 let (writer, buf) = TestWriter::new();
1311 {
1312 let _guard = TerminalGuard {
1313 writer: Some(writer),
1314 };
1315 }
1316 let bytes = buf.lock().unwrap();
1317 assert!(
1318 bytes
1319 .windows(b"\x1b[?2004l".len())
1320 .any(|w| w == b"\x1b[?2004l")
1321 );
1322 }
1323
1324 #[test]
1327 fn terminal_guard_teardown_writes_mouse_disable_ansi() {
1328 let (writer, buf) = TestWriter::new();
1329 {
1330 let _guard = TerminalGuard {
1331 writer: Some(writer),
1332 };
1333 }
1334 let bytes = buf.lock().unwrap();
1335 assert!(
1336 bytes
1337 .windows(b"\x1b[?1000l".len())
1338 .any(|w| w == b"\x1b[?1000l"),
1339 "TerminalGuard teardown must emit the mouse-disable ANSI"
1340 );
1341 }
1342
1343 #[test]
1346 fn init_and_teardown_roundtrip_contains_both_sequences() {
1347 let (writer, buf) = TestWriter::new();
1348 let guard = init_terminal(writer).expect("init_terminal");
1349 drop(guard);
1350 let bytes = buf.lock().unwrap();
1351 assert!(
1352 bytes
1353 .windows(b"\x1b[?2004h".len())
1354 .any(|w| w == b"\x1b[?2004h")
1355 );
1356 assert!(
1357 bytes
1358 .windows(b"\x1b[?2004l".len())
1359 .any(|w| w == b"\x1b[?2004l")
1360 );
1361 }
1362
1363 #[test]
1366 fn test_prev_parser_resize_sync_matches_fresh_parser() {
1367 let mut prev_parser = term_wm_vt100::Parser::new(24, 80, 0);
1368 prev_parser.process(b"initial screen content");
1369
1370 let (new_rows, new_cols) = (40, 120);
1372 let new_formatted_content = {
1373 let mut p = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1374 p.process(b"resized screen content");
1375 p.screen().contents_formatted().to_vec()
1376 };
1377
1378 prev_parser.screen_mut().set_size(new_rows, new_cols);
1380 prev_parser.process(b"\x1bc");
1381 prev_parser.process(&new_formatted_content);
1382
1383 let mut fresh_parser = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1385 fresh_parser.process(&new_formatted_content);
1386
1387 assert_eq!(
1388 prev_parser.screen().contents_formatted(),
1389 fresh_parser.screen().contents_formatted(),
1390 "Reused parser state after set_size + RIS must match fresh parser"
1391 );
1392 }
1393
1394 #[test]
1395 fn render_frame_outputs_correct_cup_and_sgr() {
1396 let mut parser = term_wm_vt100::Parser::new(4, 8, 0);
1397 parser.process(b"\x1b[31mhello\x1b[0m");
1398 let screen = parser.screen();
1399 let mut buf: Vec<u8> = Vec::new();
1400 let (rows, cols) = screen.size();
1401 render_frame(&mut buf, screen, rows, cols, false).unwrap();
1402 let output = String::from_utf8_lossy(&buf);
1403 assert!(output.contains("\x1b[1;1H"));
1405 assert!(output.contains("\x1b[2;1H"));
1406 assert!(output.contains("\x1b[3;1H"));
1407 assert!(output.contains("\x1b[4;1H"));
1408 assert!(output.contains("hello"));
1410 assert!(
1412 output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
1413 "Expected red foreground SGR in output: {output:?}"
1414 );
1415 assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
1417 }
1418
1419 #[test]
1422 fn coalesce_moved_with_moved() {
1423 assert!(is_coalescable_mouse(
1424 &MouseEventKind::Moved,
1425 &KeyModifiers::NONE,
1426 &MouseEventKind::Moved,
1427 &KeyModifiers::NONE,
1428 ));
1429 }
1430
1431 #[test]
1432 fn coalesce_drag_same_button() {
1433 assert!(is_coalescable_mouse(
1434 &MouseEventKind::Drag(MouseButton::Left),
1435 &KeyModifiers::NONE,
1436 &MouseEventKind::Drag(MouseButton::Left),
1437 &KeyModifiers::NONE,
1438 ));
1439 assert!(is_coalescable_mouse(
1440 &MouseEventKind::Drag(MouseButton::Right),
1441 &KeyModifiers {
1442 shift: true,
1443 ..KeyModifiers::NONE
1444 },
1445 &MouseEventKind::Drag(MouseButton::Right),
1446 &KeyModifiers {
1447 shift: true,
1448 ..KeyModifiers::NONE
1449 },
1450 ));
1451 }
1452
1453 #[test]
1454 fn reject_drag_different_button() {
1455 assert!(!is_coalescable_mouse(
1456 &MouseEventKind::Drag(MouseButton::Left),
1457 &KeyModifiers::NONE,
1458 &MouseEventKind::Drag(MouseButton::Right),
1459 &KeyModifiers::NONE,
1460 ));
1461 }
1462
1463 #[test]
1464 fn reject_moved_vs_drag() {
1465 assert!(!is_coalescable_mouse(
1466 &MouseEventKind::Moved,
1467 &KeyModifiers::NONE,
1468 &MouseEventKind::Drag(MouseButton::Left),
1469 &KeyModifiers::NONE,
1470 ));
1471 }
1472
1473 #[test]
1474 fn reject_different_modifiers() {
1475 assert!(!is_coalescable_mouse(
1476 &MouseEventKind::Moved,
1477 &KeyModifiers::NONE,
1478 &MouseEventKind::Moved,
1479 &KeyModifiers {
1480 shift: true,
1481 ..KeyModifiers::NONE
1482 },
1483 ));
1484 assert!(!is_coalescable_mouse(
1485 &MouseEventKind::Drag(MouseButton::Left),
1486 &KeyModifiers {
1487 control: true,
1488 ..KeyModifiers::NONE
1489 },
1490 &MouseEventKind::Drag(MouseButton::Left),
1491 &KeyModifiers::NONE,
1492 ));
1493 }
1494
1495 #[test]
1496 fn reject_discrete_events() {
1497 assert!(!is_coalescable_mouse(
1498 &MouseEventKind::Press(MouseButton::Left),
1499 &KeyModifiers::NONE,
1500 &MouseEventKind::Press(MouseButton::Left),
1501 &KeyModifiers::NONE,
1502 ));
1503 assert!(!is_coalescable_mouse(
1504 &MouseEventKind::Release(MouseButton::Left),
1505 &KeyModifiers::NONE,
1506 &MouseEventKind::Moved,
1507 &KeyModifiers::NONE,
1508 ));
1509 assert!(!is_coalescable_mouse(
1510 &MouseEventKind::Moved,
1511 &KeyModifiers::NONE,
1512 &MouseEventKind::ScrollDown,
1513 &KeyModifiers::NONE,
1514 ));
1515 assert!(!is_coalescable_mouse(
1516 &MouseEventKind::ScrollUp,
1517 &KeyModifiers::NONE,
1518 &MouseEventKind::ScrollUp,
1519 &KeyModifiers::NONE,
1520 ));
1521 }
1522
1523 fn coalesce_through(
1528 events: &[Event],
1529 kind: MouseEventKind,
1530 modifiers: KeyModifiers,
1531 ) -> Option<Event> {
1532 let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
1533 for e in events.iter().cloned() {
1534 tx.send(e).ok();
1535 }
1536 drop(tx);
1537
1538 let mut result = Event::Mouse(MouseEvent {
1539 kind,
1540 modifiers,
1541 column: 0,
1542 row: 0,
1543 });
1544
1545 if let Event::Mouse(ref mut mouse) = result
1546 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
1547 {
1548 while let Ok(next) = rx.try_recv() {
1549 match next {
1550 Event::Mouse(ref next_mouse)
1551 if is_coalescable_mouse(
1552 &mouse.kind,
1553 &mouse.modifiers,
1554 &next_mouse.kind,
1555 &next_mouse.modifiers,
1556 ) =>
1557 {
1558 *mouse = *next_mouse;
1559 }
1560 _other => return Some(result),
1561 }
1562 }
1563 }
1564
1565 Some(result)
1566 }
1567
1568 #[test]
1569 fn coalesce_keeps_latest_moved_position() {
1570 let events = vec![
1571 Event::Mouse(MouseEvent {
1572 kind: MouseEventKind::Moved,
1573 modifiers: KeyModifiers::NONE,
1574 column: 5,
1575 row: 5,
1576 }),
1577 Event::Mouse(MouseEvent {
1578 kind: MouseEventKind::Moved,
1579 modifiers: KeyModifiers::NONE,
1580 column: 10,
1581 row: 10,
1582 }),
1583 Event::Mouse(MouseEvent {
1584 kind: MouseEventKind::Moved,
1585 modifiers: KeyModifiers::NONE,
1586 column: 15,
1587 row: 15,
1588 }),
1589 ];
1590 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1591 let Event::Mouse(m) = result.unwrap() else {
1592 panic!("expected mouse")
1593 };
1594 assert_eq!((m.column, m.row), (15, 15));
1595 }
1596
1597 #[test]
1598 fn coalesce_keeps_latest_drag_position() {
1599 let events = vec![
1600 Event::Mouse(MouseEvent {
1601 kind: MouseEventKind::Drag(MouseButton::Left),
1602 modifiers: KeyModifiers::NONE,
1603 column: 1,
1604 row: 1,
1605 }),
1606 Event::Mouse(MouseEvent {
1607 kind: MouseEventKind::Drag(MouseButton::Left),
1608 modifiers: KeyModifiers::NONE,
1609 column: 2,
1610 row: 2,
1611 }),
1612 ];
1613 let result = coalesce_through(
1614 &events,
1615 MouseEventKind::Drag(MouseButton::Left),
1616 KeyModifiers::NONE,
1617 );
1618 let Event::Mouse(m) = result.unwrap() else {
1619 panic!("expected mouse")
1620 };
1621 assert_eq!((m.column, m.row), (2, 2));
1622 }
1623
1624 #[test]
1625 fn coalesce_stops_at_modifier_change() {
1626 let events = vec![Event::Mouse(MouseEvent {
1627 kind: MouseEventKind::Moved,
1628 modifiers: KeyModifiers {
1629 shift: true,
1630 ..KeyModifiers::NONE
1631 },
1632 column: 99,
1633 row: 99,
1634 })];
1635 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1636 let Event::Mouse(m) = result.unwrap() else {
1637 panic!("expected mouse")
1638 };
1639 assert_eq!((m.column, m.row), (0, 0));
1642 }
1643
1644 #[test]
1645 fn coalesce_stops_at_non_mouse_event() {
1646 let key = Event::Key(KeyEvent {
1647 code: KeyCode::Char('q'),
1648 kind: KeyKind::Press,
1649 modifiers: KeyModifiers::NONE,
1650 });
1651 let events = vec![key.clone()];
1652 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1653 let Event::Mouse(m) = result.unwrap() else {
1654 panic!("expected mouse")
1655 };
1656 assert_eq!((m.column, m.row), (0, 0));
1658 }
1659
1660 #[test]
1661 fn coalesce_stops_at_discrete_mouse_event() {
1662 let events = vec![Event::Mouse(MouseEvent {
1663 kind: MouseEventKind::Press(MouseButton::Left),
1664 modifiers: KeyModifiers::NONE,
1665 column: 10,
1666 row: 10,
1667 })];
1668 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1669 let Event::Mouse(m) = result.unwrap() else {
1670 panic!("expected mouse")
1671 };
1672 assert_eq!((m.column, m.row), (0, 0));
1674 }
1675}
1676
1677#[allow(clippy::unwrap_used)]
1681#[cfg(test)]
1682#[allow(clippy::type_complexity)]
1683mod snapshot_tests {
1684 use super::*;
1685
1686 fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
1689 let rt = tokio::runtime::Builder::new_current_thread()
1690 .build()
1691 .expect("tokio rt");
1692 let (push_tx, push_rx) = crossbeam_channel::bounded(16);
1693 let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
1694 let mut pane = RemotePane::new(
1695 0,
1696 None,
1697 rt.handle().clone(),
1698 cols,
1699 rows,
1700 push_rx,
1701 input_writer,
1702 );
1703 drop(rt); push_tx.send(pty_bytes.to_vec()).ok();
1706 pane.drain_pushes();
1707
1708 let parser = pane.shared_parser();
1709 let parser = parser.lock().unwrap();
1710 let screen = parser.screen();
1711 let (rows, cols) = screen.size();
1712 let mut out = Vec::new();
1713 render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
1714 out
1715 }
1716
1717 fn escape_ansi(bytes: &[u8]) -> String {
1719 let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
1720 for &b in bytes {
1721 match b {
1722 b'\x1b' => out.extend_from_slice(b"\\x1b"),
1723 b'\n' => out.extend_from_slice(b"\\n"),
1724 b'\r' => out.extend_from_slice(b"\\r"),
1725 b'\t' => out.extend_from_slice(b"\\t"),
1726 0x20..=0x7e => out.push(b),
1727 _ => {
1728 out.push(b'\\');
1729 out.push(b'x');
1730 out.extend_from_slice(&hex_byte(b));
1731 }
1732 }
1733 }
1734 unsafe { String::from_utf8_unchecked(out) }
1736 }
1737
1738 fn hex_byte(b: u8) -> [u8; 2] {
1739 #[inline]
1740 fn hex_nibble(n: u8) -> u8 {
1741 let digit = n & 0x0f;
1742 if digit < 10 {
1743 b'0' + digit
1744 } else {
1745 b'a' + digit - 10
1746 }
1747 }
1748 [hex_nibble(b >> 4), hex_nibble(b)]
1749 }
1750
1751 #[test]
1754 fn snapshot_empty_grid() {
1755 let out = render_and_capture(b"", 4, 8, false);
1756 insta::assert_snapshot!("empty_grid", escape_ansi(&out));
1757 }
1758
1759 #[test]
1760 fn snapshot_basic_text() {
1761 let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
1762 insta::assert_snapshot!("basic_text", escape_ansi(&out));
1763 }
1764
1765 #[test]
1766 fn snapshot_colored_text() {
1767 let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
1768 insta::assert_snapshot!("colored_text", escape_ansi(&out));
1769 }
1770
1771 #[test]
1772 fn snapshot_normal_char_at_margin() {
1773 let out = render_and_capture(b"ABCD", 1, 4, false);
1776 insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
1777 }
1778
1779 #[test]
1780 fn snapshot_clear_display() {
1781 let out = render_and_capture(b"", 4, 8, true);
1782 insta::assert_snapshot!("clear_display", escape_ansi(&out));
1783 }
1784
1785 #[test]
1786 fn snapshot_hidden_cursor() {
1787 let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
1788 insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
1789 }
1790
1791 #[test]
1792 fn snapshot_color_across_margin() {
1793 let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
1796 insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
1797 }
1798
1799 #[test]
1800 fn snapshot_multi_row_fill() {
1801 let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
1804 insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
1805 }
1806
1807 #[test]
1808 fn snapshot_wide_char_margin() {
1809 let out = render_and_capture(
1812 b"B\xe3\x81\x82", 1,
1814 3,
1815 false,
1816 );
1817 insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
1818 }
1819
1820 #[test]
1821 fn snapshot_wide_char_middle() {
1822 let out = render_and_capture(
1826 b"A\xe3\x81\x82\xe3\x81\x83", 1,
1828 5,
1829 false,
1830 );
1831 insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1832 }
1833}