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, AttachRequest, OnPtyResized, RpcMethodPrebuffered, STREAM_INPUT_METHOD_ID,
24 SUBSCRIBE_OUTPUT_METHOD_ID, Spawn, SpawnRequest, SpawnResponse, path_wire,
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 term_wm_vt100::{MouseProtocolEncoding, MouseProtocolMode, Parser, Screen};
32
33#[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#[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#[cfg(target_os = "windows")]
108const INITIAL_WAIT_ITERS: usize = 60;
109#[cfg(not(target_os = "windows"))]
110const INITIAL_WAIT_ITERS: usize = 20;
111
112const PTY_OUTPUT_CHANNEL_CAPACITY: usize = 256;
114const CLIPBOARD_CHANNEL_CAPACITY: usize = 64;
116const INPUT_CHANNEL_CAPACITY: usize = 64;
118
119const PREV_TAIL_LEN: usize = 8;
123
124const INITIAL_WAIT_SLEEP_MS: u64 = 50;
126
127const INPUT_POLL_MS: u64 = 50;
130
131const BACKPRESSURE_SLEEP_MS: u64 = 1;
134
135const BRACKETED_PASTE_OVERHEAD: usize = 12;
138
139const RENDER_BUF_CELL_MULTIPLIER: usize = 3;
141
142const MIN_TERM_COLS: u16 = 2;
146const MIN_TERM_ROWS: u16 = 2;
147
148const FALLBACK_TERM_COLS: u16 = 80;
152const FALLBACK_TERM_ROWS: u16 = 24;
153
154pub fn init_terminal<W: Write>(mut writer: W) -> io::Result<TerminalGuard<W>> {
162 if std::io::stdin().is_terminal() {
163 enable_raw_mode()?;
164 }
165 writer.queue(EnterAlternateScreen)?;
166 writer.queue(Hide)?;
167 writer.queue(EnableBracketedPaste)?;
168 writer.queue(crossterm::event::EnableMouseCapture)?;
169 #[cfg(windows)]
177 term_wm_crossterm_adapter::set_mouse_capture_with(&mut writer, true)?;
178 writer.flush()?;
179 Ok(TerminalGuard {
180 writer: Some(writer),
181 })
182}
183
184pub struct TerminalGuard<W: Write = std::io::Stdout> {
188 writer: Option<W>,
189}
190
191impl<W: Write> Drop for TerminalGuard<W> {
192 fn drop(&mut self) {
193 if let Some(ref mut writer) = self.writer {
194 let _ = writer.queue(crossterm::event::DisableMouseCapture);
195 #[cfg(windows)]
198 let _ = term_wm_crossterm_adapter::set_mouse_capture_with(writer, false);
199 let _ = writer.queue(DisableBracketedPaste);
200 let _ = writer.queue(Show);
201 let _ = writer.queue(LeaveAlternateScreen);
202 if std::io::stdin().is_terminal() {
203 let _ = disable_raw_mode();
204 }
205 let _ = writer.flush();
206 }
207 }
208}
209
210fn convert_crossterm_event(evt: crossterm::event::Event) -> Option<Event> {
212 term_wm_crossterm_adapter::try_translate_event(evt)
213}
214
215fn is_coalescable_mouse(
220 a_kind: &MouseEventKind,
221 a_mod: &KeyModifiers,
222 b_kind: &MouseEventKind,
223 b_mod: &KeyModifiers,
224) -> bool {
225 if a_mod != b_mod {
226 return false;
227 }
228 match (a_kind, b_kind) {
229 (MouseEventKind::Moved, MouseEventKind::Moved) => true,
230 (MouseEventKind::Drag(btn1), MouseEventKind::Drag(btn2)) => btn1 == btn2,
231 _ => false,
232 }
233}
234
235fn client_user() -> String {
240 #[cfg(unix)]
241 {
242 unsafe {
243 let pw = libc::getpwuid(libc::getuid());
244 if !pw.is_null() {
245 let name = std::ffi::CStr::from_ptr((*pw).pw_name);
246 if let Ok(s) = name.to_str()
247 && !s.is_empty()
248 {
249 return s.to_string();
250 }
251 }
252 }
253 std::env::var("USER").unwrap_or_default()
254 }
255 #[cfg(windows)]
256 {
257 if let Ok(u) = std::env::var("USERNAME")
258 && !u.is_empty()
259 {
260 return u;
261 }
262 windows_username().unwrap_or_default()
263 }
264 #[cfg(not(any(unix, windows)))]
265 {
266 String::new()
267 }
268}
269
270#[cfg(windows)]
273fn windows_username() -> Option<String> {
274 use std::os::windows::ffi::OsStringExt;
275 use windows_sys::Win32::System::WindowsProgramming::GetUserNameW;
276 let mut buf = [0u16; 256];
277 let mut len = buf.len() as u32;
278 let ok = unsafe { GetUserNameW(buf.as_mut_ptr(), &mut len) };
279 if ok == 0 {
280 return None;
281 }
282 let s = std::ffi::OsString::from_wide(&buf[..len as usize])
283 .to_string_lossy()
284 .into_owned();
285 if s.is_empty() { None } else { Some(s) }
286}
287
288fn client_version() -> String {
291 env!("CARGO_PKG_VERSION").to_string()
292}
293
294fn client_ssh_ip() -> Option<String> {
298 for var in ["SSH_CLIENT", "SSH_CONNECTION"] {
299 if let Ok(v) = std::env::var(var) {
300 let ip = v.split_whitespace().next()?;
301 if !ip.is_empty() {
302 return Some(ip.to_string());
303 }
304 }
305 }
306 None
307}
308
309pub fn run_session(socket_path: &str, channel: &str, cmd: &[String]) -> io::Result<()> {
320 #[cfg(windows)]
325 disable_quick_edit();
326
327 let rt =
328 tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
329
330 let client: Arc<RpcIpcClient> = rt
332 .block_on(RpcIpcClient::new(socket_path))
333 .map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, format!("{e:?}")))?;
334
335 let abi_fault = |e: &dyn std::fmt::Display| -> io::Error {
339 io::Error::other(format!(
340 "FATAL: Protocol ABI mismatch. A legacy daemon may be occupying the IPC socket. Manually terminate the daemon process before continuing. (cause: {e})"
341 ))
342 };
343
344 let server_cols = Arc::new(AtomicU16::new(0));
348 let server_rows = Arc::new(AtomicU16::new(0));
349 let resize_pending = Arc::new(AtomicBool::new(false));
350
351 {
352 let cols_ref = Arc::clone(&server_cols);
353 let rows_ref = Arc::clone(&server_rows);
354 let pending_ref = Arc::clone(&resize_pending);
355 rt.block_on(client.get_endpoint().register_prebuffered(
356 OnPtyResized::METHOD_ID,
357 move |payload, _ctx| {
358 let cols_ref = Arc::clone(&cols_ref);
359 let rows_ref = Arc::clone(&rows_ref);
360 let pending_ref = Arc::clone(&pending_ref);
361 async move {
362 let (cols, rows) = OnPtyResized::decode_request(&payload)
363 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
364 cols_ref.store(cols, Ordering::Relaxed);
365 rows_ref.store(rows, Ordering::Relaxed);
366 pending_ref.store(true, Ordering::Relaxed);
367 OnPtyResized::encode_response(())
368 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
369 }
370 },
371 ))
372 .map_err(|e| io::Error::other(format!("register OnPtyResized: {e:?}")))?;
373 }
374
375 let (push_tx, push_rx) = crossbeam_channel::bounded::<Vec<u8>>(PTY_OUTPUT_CHANNEL_CAPACITY);
379 let (clip_tx, clip_rx) = crossbeam_channel::bounded::<String>(CLIPBOARD_CHANNEL_CAPACITY);
380
381 let (term_cols, term_rows) = match crossterm::terminal::size() {
388 Ok((c, r)) => (c.max(MIN_TERM_COLS), r.max(MIN_TERM_ROWS)),
389 Err(_) => (FALLBACK_TERM_COLS, FALLBACK_TERM_ROWS),
390 };
391 let hostname = hostname::get()
392 .map(|h| h.to_string_lossy().into_owned())
393 .unwrap_or_else(|_| "unknown".to_string());
394
395 let (actual_cols, actual_rows) = rt.block_on(async {
396 let conn_id = Attach::call(
399 &*client,
400 AttachRequest {
401 channel: channel.to_string(),
402 hostname,
403 pid: std::process::id() as u64,
404 user: client_user(),
405 version: client_version(),
406 ssh_ip: client_ssh_ip(),
407 },
408 )
409 .await
410 .map_err(|e| abi_fault(&e))?;
411 let cmd = if cmd.is_empty() {
413 None
414 } else {
415 Some(cmd.to_vec())
416 };
417 let launch_cwd = std::env::current_dir().ok().map(path_wire::encode_path);
423 let SpawnResponse {
424 id: _session_id,
425 cols: actual_cols,
426 rows: actual_rows,
427 ..
428 } = Spawn::call(
429 &*client,
430 SpawnRequest {
431 cmd,
432 cols: term_cols,
433 rows: term_rows,
434 cwd: launch_cwd,
435 },
436 )
437 .await
438 .map_err(|e| abi_fault(&e))?;
439 let _ = conn_id;
440 Ok::<(u16, u16), io::Error>((actual_cols, actual_rows))
441 })?;
442
443 let writer = rt.block_on(async {
445 let (_, mut reader) = client
448 .open_channel(SUBSCRIBE_OUTPUT_METHOD_ID, 0)
449 .await
450 .map_err(|e| io::Error::other(format!("subscribe: {e:?}")))?;
451
452 rt.spawn(async move {
456 let mut osc52 = Osc52Extractor::new();
457 let mut prev_tail: [u8; PREV_TAIL_LEN] = [0; PREV_TAIL_LEN];
458
459 while let Some(chunk) = reader.recv().await {
460 if let Ok(mut data) = chunk {
461 if let Some(text) = osc52.push(&data, &prev_tail) {
462 let _ = clip_tx.try_send(text);
463 }
464
465 let n = data.len();
466 if n >= PREV_TAIL_LEN {
467 prev_tail.copy_from_slice(&data[n - PREV_TAIL_LEN..n]);
468 } else if n > 0 {
469 prev_tail.rotate_left(n);
470 prev_tail[PREV_TAIL_LEN - n..].copy_from_slice(&data[..n]);
471 }
472
473 while let Err(crossbeam_channel::TrySendError::Full(pending)) =
476 push_tx.try_send(data)
477 {
478 data = pending;
479 tokio::time::sleep(Duration::from_millis(BACKPRESSURE_SLEEP_MS)).await;
480 }
481 } else {
482 break;
483 }
484 }
485 if let Some(text) = osc52.finish() {
488 let _ = clip_tx.try_send(text);
489 }
490 });
491
492 let (writer, _) = client
495 .open_channel(STREAM_INPUT_METHOD_ID, 0)
496 .await
497 .map_err(|e| io::Error::other(format!("stream input: {e:?}")))?;
498
499 Ok::<_, io::Error>(writer)
500 })?;
501
502 let input_writer = Box::new(move |data: &[u8]| -> io::Result<()> {
503 writer
504 .send(data.to_vec())
505 .map_err(|e| io::Error::other(e.to_string()))?;
506 Ok(())
507 });
508
509 let mut pane = RemotePane::new(
510 1u64,
511 Some(client.clone()),
512 rt.handle().clone(),
513 term_cols,
514 term_rows,
515 push_rx.clone(),
516 input_writer,
517 );
518
519 for _ in 0..INITIAL_WAIT_ITERS {
521 pane.drain_pushes();
522 let parser = pane.shared_parser();
523 let parser = parser.lock().unwrap();
524 if !parser.screen().contents_formatted().is_empty() {
525 break;
526 }
527 drop(parser);
528 std::thread::sleep(Duration::from_millis(INITIAL_WAIT_SLEEP_MS));
529 }
530
531 {
533 let parser = pane.shared_parser();
534 let mut parser_lk = parser.lock().unwrap();
535 let (cur_rows, cur_cols) = parser_lk.screen().size();
536 if actual_cols != cur_cols || actual_rows != cur_rows {
537 parser_lk.screen_mut().set_size(actual_rows, actual_cols);
538 }
539 drop(parser_lk);
540 }
541
542 #[cfg(unix)]
553 let _ = redirect_fd_to_tracing(libc::STDERR_FILENO, true);
554 let _guard = init_terminal(stdout())?;
555 let mut out = stdout();
556
557 let mut clipboard = Clipboard::new();
558 let sigint = install_sigint_handler()?;
559
560 let (input_tx, input_rx) = crossbeam_channel::bounded::<Event>(INPUT_CHANNEL_CAPACITY);
562
563 std::thread::Builder::new()
567 .name("crossterm-input".into())
568 .spawn(move || {
569 loop {
570 match crossterm::event::poll(Duration::from_millis(INPUT_POLL_MS)) {
571 Ok(true) => {
572 if let Ok(crossterm_evt) = crossterm::event::read()
573 && let Some(e) = convert_crossterm_event(crossterm_evt)
574 && input_tx.send(e).is_err()
575 {
576 break;
577 }
578 }
579 Ok(false) => continue,
580 Err(_) => break,
581 }
582 }
583 })
584 .map_err(|e| io::Error::other(format!("spawn input thread: {e}")))?;
585
586 {
588 let parser = pane.shared_parser();
589 let parser = parser.lock().unwrap();
590 let screen = parser.screen();
591 let (rows, cols) = screen.size();
592 render_frame(&mut out, screen, rows, cols, false)?;
593 }
594
595 let mut pending_input: Option<Event> = None;
596 loop {
597 let mut force_render = false;
598 let mut clear_display = false;
599
600 let apply_pending_resize = |shared_parser: &Arc<Mutex<Parser>>| -> bool {
603 if resize_pending.swap(false, Ordering::Relaxed) {
604 let cols = server_cols.load(Ordering::Relaxed);
605 let rows = server_rows.load(Ordering::Relaxed);
606 if cols > 0 && rows > 0 {
607 let mut parser_lk = shared_parser.lock().unwrap();
608 let (cur_rows, cur_cols) = parser_lk.screen().size();
609 if cur_cols != cols || cur_rows != rows {
610 parser_lk.screen_mut().set_size(rows, cols);
611 return true;
612 }
613 }
614 }
615 false
616 };
617
618 let resized = apply_pending_resize(&pane.shared_parser());
620 force_render |= resized;
621 clear_display |= resized;
622
623 let input_event = if let Some(evt) = pending_input.take() {
626 Some(evt)
627 } else {
628 crossbeam_channel::select! {
629 recv(input_rx) -> msg => {
630 match msg {
631 Ok(evt) => Some(evt),
632 Err(_) => return Err(io::Error::other("input thread died")),
633 }
634 }
635 recv(push_rx) -> msg => {
636 match msg {
637 Ok(data) => {
638 let resized = apply_pending_resize(&pane.shared_parser());
643 force_render |= resized;
644 clear_display |= resized;
645
646 let parser = pane.shared_parser();
648 let mut parser = parser.lock().unwrap();
649 parser.process(&data);
650 None
651 }
652 Err(_) => {
653 None
656 }
657 }
658 }
659 }
660 };
661
662 let has_new_data = pane.drain_pushes() || input_event.is_none();
664
665 while let Ok(text) = clip_rx.try_recv() {
667 clipboard.set(&text);
668 }
669
670 if sigint.received() {
672 sigint.ack();
673 let _ = pane.write_bytes(&[0x03]);
674 }
675
676 if let Some(mut evt) = input_event {
678 if let Event::Mouse(ref mut mouse) = evt
683 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
684 {
685 while let Ok(next_evt) = input_rx.try_recv() {
686 match next_evt {
687 Event::Mouse(ref next_mouse)
688 if is_coalescable_mouse(
689 &mouse.kind,
690 &mouse.modifiers,
691 &next_mouse.kind,
692 &next_mouse.modifiers,
693 ) =>
694 {
695 *mouse = *next_mouse;
696 }
697 other => {
698 pending_input = Some(other);
699 break;
700 }
701 }
702 }
703 }
704
705 match evt {
706 Event::Key(ref key)
707 if key.kind == KeyKind::Press || key.kind == KeyKind::Repeat =>
708 {
709 let bytes = key_to_bytes(key, false);
710 if !bytes.is_empty() {
711 let _ = pane.write_bytes(&bytes);
712 }
713 }
714 Event::Mouse(ref mouse) => {
715 let mouse_active = {
716 let parser = pane.shared_parser();
717 let parser = parser.lock().unwrap();
718 parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
719 };
720 if mouse_active {
721 let bytes = mouse_event_to_bytes(mouse, MouseProtocolEncoding::Sgr);
722 if !bytes.is_empty() {
723 let _ = pane.write_bytes(&bytes);
724 }
725 }
726 }
727 Event::Resize(w, h) => {
728 let size = PtySize {
729 rows: h,
730 cols: w,
731 pixel_width: 0,
732 pixel_height: 0,
733 };
734 if let Err(err) = pane.resize(size) {
735 tracing::warn!(error = %err, "resize request failed on PTY pane");
736 }
737 force_render = true;
738 clear_display = true;
739 }
740 Event::Paste(text) => {
741 let mut wrapped = Vec::with_capacity(text.len() + BRACKETED_PASTE_OVERHEAD);
742 wrapped.extend_from_slice(b"\x1b[200~");
743 wrapped.extend_from_slice(text.as_bytes());
744 wrapped.extend_from_slice(b"\x1b[201~");
745 let _ = pane.write_bytes(&wrapped);
746 }
747 _ => {}
748 }
749 }
750
751 if !client.is_connected() {
753 return Err(io::Error::other("connection to session server lost"));
754 }
755
756 if has_new_data || force_render {
758 let parser = pane.shared_parser();
759 let parser = parser.lock().unwrap();
760 let screen = parser.screen();
761 let (rows, cols) = screen.size();
762 render_frame(&mut out, screen, rows, cols, clear_display)?;
763 }
764
765 if pane.has_exited() {
767 return Ok(());
768 }
769 }
770}
771
772#[derive(Default, PartialEq, Clone, Copy)]
773struct CellStyle {
774 fg: term_wm_vt100::Color,
775 bg: term_wm_vt100::Color,
776 bold: bool,
777 dim: bool,
778 italic: bool,
779 underline: bool,
780 inverse: bool,
781}
782
783impl CellStyle {
784 fn from_cell(cell: &term_wm_vt100::Cell) -> Self {
785 Self {
786 fg: cell.fgcolor(),
787 bg: cell.bgcolor(),
788 bold: cell.bold(),
789 dim: cell.dim(),
790 italic: cell.italic(),
791 underline: cell.underline(),
792 inverse: cell.inverse(),
793 }
794 }
795}
796
797fn apply_sgr(out: &mut dyn Write, style: &CellStyle) -> io::Result<()> {
798 write!(out, "\x1b[0m")?;
799 if style.bold {
800 write!(out, "\x1b[1m")?;
801 }
802 if style.dim {
803 write!(out, "\x1b[2m")?;
804 }
805 if style.italic {
806 write!(out, "\x1b[3m")?;
807 }
808 if style.underline {
809 write!(out, "\x1b[4m")?;
810 }
811 if style.inverse {
812 write!(out, "\x1b[7m")?;
813 }
814 match style.fg {
815 term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[38;5;{}m", i)?,
816 term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[38;2;{};{};{}m", r, g, b)?,
817 _ => {}
818 }
819 match style.bg {
820 term_wm_vt100::Color::Idx(i) => write!(out, "\x1b[48;5;{}m", i)?,
821 term_wm_vt100::Color::Rgb(r, g, b) => write!(out, "\x1b[48;2;{};{};{}m", r, g, b)?,
822 _ => {}
823 }
824 Ok(())
825}
826
827pub fn render_frame(
828 out: &mut dyn Write,
829 screen: &Screen,
830 rows: u16,
831 cols: u16,
832 clear_display: bool,
833) -> io::Result<()> {
834 let mut buf =
835 Vec::with_capacity((rows as usize) * (cols as usize) * RENDER_BUF_CELL_MULTIPLIER);
836 let mut active_style = CellStyle::default();
837
838 buf.extend_from_slice(b"\x1b[?2026h\x1b[?25l\x1b[0m");
840 if clear_display {
841 buf.extend_from_slice(b"\x1b[2J");
842 }
843 buf.extend_from_slice(b"\x1b[?7l");
844
845 for row in 0..rows {
846 write!(buf, "\x1b[{};1H", row + 1)?;
847
848 let mut col: u16 = 0;
849 while col < cols {
850 let cell_opt = screen.cell(row, col);
854 let contents = cell_opt.map_or("", |c| c.contents());
855 let width = if contents.is_empty() {
856 1
857 } else {
858 unicode_width::UnicodeWidthStr::width(contents).max(1) as u16
859 };
860
861 if col + width >= cols {
865 buf.extend_from_slice(b"\x1b[0m\x1b[K");
866 active_style = CellStyle::default();
867 }
868
869 let style = cell_opt.map(CellStyle::from_cell).unwrap_or_default();
870 if style != active_style {
871 apply_sgr(&mut buf, &style)?;
872 active_style = style;
873 }
874
875 if contents.is_empty() {
876 buf.push(b' ');
877 } else {
878 buf.extend_from_slice(contents.as_bytes());
879 }
880
881 col += width;
882 }
883 }
884
885 buf.extend_from_slice(b"\x1b[?7h");
886 buf.extend_from_slice(b"\x1b[0m");
887 let (cur_row, cur_col) = screen.cursor_position();
888 write!(buf, "\x1b[{};{}H", cur_row + 1, cur_col + 1)?;
889 if screen.hide_cursor() {
890 buf.extend_from_slice(b"\x1b[?25l");
891 } else {
892 buf.extend_from_slice(b"\x1b[?25h");
893 }
894 buf.extend_from_slice(b"\x1b[?2026l");
896
897 out.write_all(&buf)?;
898 out.flush()
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904 use std::sync::{Arc, Mutex};
905
906 use term_wm_events::{KeyCode, KeyEvent, MouseButton, MouseEvent};
907
908 struct TestWriter {
909 buf: Arc<Mutex<Vec<u8>>>,
910 }
911
912 impl TestWriter {
913 fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
914 let buf = Arc::new(Mutex::new(Vec::new()));
915 (Self { buf: buf.clone() }, buf)
916 }
917 }
918
919 impl Write for TestWriter {
920 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
921 self.buf.lock().unwrap().extend_from_slice(buf);
922 Ok(buf.len())
923 }
924 fn flush(&mut self) -> io::Result<()> {
925 Ok(())
926 }
927 }
928
929 #[test]
934 fn init_terminal_writes_bracketed_paste_enable() {
935 let (writer, buf) = TestWriter::new();
936 let _guard = init_terminal(writer).expect("init_terminal");
937 let bytes = buf.lock().unwrap();
938 assert!(
939 bytes
940 .windows(b"\x1b[?2004h".len())
941 .any(|w| w == b"\x1b[?2004h")
942 );
943 }
944
945 #[test]
950 fn init_terminal_writes_mouse_enable_ansi() {
951 let (writer, buf) = TestWriter::new();
952 let _guard = init_terminal(writer).expect("init_terminal");
953 let bytes = buf.lock().unwrap();
954 assert!(
955 bytes
956 .windows(b"\x1b[?1000h".len())
957 .any(|w| w == b"\x1b[?1000h"),
958 "init_terminal must emit the mouse-enable ANSI so a host routes mouse input"
959 );
960 }
961
962 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
968 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
969 LOCK.lock().unwrap_or_else(|e| e.into_inner())
970 }
971
972 #[test]
973 fn client_ssh_ip_from_ssh_client() {
974 let _guard = env_lock();
975 unsafe {
976 std::env::set_var("SSH_CLIENT", "192.168.1.50 54321 22");
977 std::env::remove_var("SSH_CONNECTION");
978 }
979 assert_eq!(client_ssh_ip().as_deref(), Some("192.168.1.50"));
980 unsafe {
981 std::env::remove_var("SSH_CLIENT");
982 }
983 }
984
985 #[test]
986 fn client_ssh_ip_from_ssh_connection_fallback() {
987 let _guard = env_lock();
988 unsafe {
989 std::env::remove_var("SSH_CLIENT");
990 std::env::set_var("SSH_CONNECTION", "10.0.0.7 48000 10.0.0.1 22");
991 }
992 assert_eq!(client_ssh_ip().as_deref(), Some("10.0.0.7"));
993 unsafe {
994 std::env::remove_var("SSH_CONNECTION");
995 }
996 }
997
998 #[test]
999 fn client_ssh_ip_ssh_client_wins_over_connection() {
1000 let _guard = env_lock();
1001 unsafe {
1002 std::env::set_var("SSH_CLIENT", "1.2.3.4 1000 22");
1003 std::env::set_var("SSH_CONNECTION", "9.9.9.9 2000 1.1.1.1 22");
1004 }
1005 assert_eq!(client_ssh_ip().as_deref(), Some("1.2.3.4"));
1006 unsafe {
1007 std::env::remove_var("SSH_CLIENT");
1008 std::env::remove_var("SSH_CONNECTION");
1009 }
1010 }
1011
1012 #[test]
1013 fn client_ssh_ip_none_when_local() {
1014 let _guard = env_lock();
1015 unsafe {
1016 std::env::remove_var("SSH_CLIENT");
1017 std::env::remove_var("SSH_CONNECTION");
1018 }
1019 assert_eq!(client_ssh_ip(), None);
1020 }
1021
1022 #[test]
1023 fn client_version_matches_package() {
1024 assert_eq!(client_version(), env!("CARGO_PKG_VERSION"));
1025 }
1026
1027 #[test]
1028 fn client_user_non_empty() {
1029 assert!(!client_user().is_empty(), "client user must resolve");
1030 }
1031
1032 #[test]
1033 #[cfg(windows)]
1034 fn client_user_prefers_username_env_when_set() {
1035 let _guard = env_lock();
1036 unsafe {
1037 std::env::set_var("USERNAME", "win-test-user");
1038 }
1039 assert_eq!(client_user(), "win-test-user");
1040 unsafe {
1041 std::env::remove_var("USERNAME");
1042 }
1043 }
1044
1045 #[test]
1046 #[cfg(windows)]
1047 fn client_user_falls_back_to_getusername_when_env_absent() {
1048 let _guard = env_lock();
1049 unsafe {
1050 std::env::remove_var("USERNAME");
1051 }
1052 assert!(
1055 !client_user().is_empty(),
1056 "GetUserNameW fallback must resolve a user"
1057 );
1058 }
1059
1060 #[test]
1063 fn terminal_guard_teardown_writes_bracketed_paste_disable() {
1064 let (writer, buf) = TestWriter::new();
1065 {
1066 let _guard = TerminalGuard {
1067 writer: Some(writer),
1068 };
1069 }
1070 let bytes = buf.lock().unwrap();
1071 assert!(
1072 bytes
1073 .windows(b"\x1b[?2004l".len())
1074 .any(|w| w == b"\x1b[?2004l")
1075 );
1076 }
1077
1078 #[test]
1081 fn terminal_guard_teardown_writes_mouse_disable_ansi() {
1082 let (writer, buf) = TestWriter::new();
1083 {
1084 let _guard = TerminalGuard {
1085 writer: Some(writer),
1086 };
1087 }
1088 let bytes = buf.lock().unwrap();
1089 assert!(
1090 bytes
1091 .windows(b"\x1b[?1000l".len())
1092 .any(|w| w == b"\x1b[?1000l"),
1093 "TerminalGuard teardown must emit the mouse-disable ANSI"
1094 );
1095 }
1096
1097 #[test]
1100 fn init_and_teardown_roundtrip_contains_both_sequences() {
1101 let (writer, buf) = TestWriter::new();
1102 let guard = init_terminal(writer).expect("init_terminal");
1103 drop(guard);
1104 let bytes = buf.lock().unwrap();
1105 assert!(
1106 bytes
1107 .windows(b"\x1b[?2004h".len())
1108 .any(|w| w == b"\x1b[?2004h")
1109 );
1110 assert!(
1111 bytes
1112 .windows(b"\x1b[?2004l".len())
1113 .any(|w| w == b"\x1b[?2004l")
1114 );
1115 }
1116
1117 #[test]
1120 fn test_prev_parser_resize_sync_matches_fresh_parser() {
1121 let mut prev_parser = term_wm_vt100::Parser::new(24, 80, 0);
1122 prev_parser.process(b"initial screen content");
1123
1124 let (new_rows, new_cols) = (40, 120);
1126 let new_formatted_content = {
1127 let mut p = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1128 p.process(b"resized screen content");
1129 p.screen().contents_formatted().to_vec()
1130 };
1131
1132 prev_parser.screen_mut().set_size(new_rows, new_cols);
1134 prev_parser.process(b"\x1bc");
1135 prev_parser.process(&new_formatted_content);
1136
1137 let mut fresh_parser = term_wm_vt100::Parser::new(new_rows, new_cols, 0);
1139 fresh_parser.process(&new_formatted_content);
1140
1141 assert_eq!(
1142 prev_parser.screen().contents_formatted(),
1143 fresh_parser.screen().contents_formatted(),
1144 "Reused parser state after set_size + RIS must match fresh parser"
1145 );
1146 }
1147
1148 #[test]
1149 fn render_frame_outputs_correct_cup_and_sgr() {
1150 let mut parser = term_wm_vt100::Parser::new(4, 8, 0);
1151 parser.process(b"\x1b[31mhello\x1b[0m");
1152 let screen = parser.screen();
1153 let mut buf: Vec<u8> = Vec::new();
1154 let (rows, cols) = screen.size();
1155 render_frame(&mut buf, screen, rows, cols, false).unwrap();
1156 let output = String::from_utf8_lossy(&buf);
1157 assert!(output.contains("\x1b[1;1H"));
1159 assert!(output.contains("\x1b[2;1H"));
1160 assert!(output.contains("\x1b[3;1H"));
1161 assert!(output.contains("\x1b[4;1H"));
1162 assert!(output.contains("hello"));
1164 assert!(
1166 output.contains("\x1b[38;5;1m") || output.contains("\x1b[31m"),
1167 "Expected red foreground SGR in output: {output:?}"
1168 );
1169 assert!(!output.contains("\x1b\x1b"), "no double ESC sequences");
1171 }
1172
1173 #[test]
1176 fn coalesce_moved_with_moved() {
1177 assert!(is_coalescable_mouse(
1178 &MouseEventKind::Moved,
1179 &KeyModifiers::NONE,
1180 &MouseEventKind::Moved,
1181 &KeyModifiers::NONE,
1182 ));
1183 }
1184
1185 #[test]
1186 fn coalesce_drag_same_button() {
1187 assert!(is_coalescable_mouse(
1188 &MouseEventKind::Drag(MouseButton::Left),
1189 &KeyModifiers::NONE,
1190 &MouseEventKind::Drag(MouseButton::Left),
1191 &KeyModifiers::NONE,
1192 ));
1193 assert!(is_coalescable_mouse(
1194 &MouseEventKind::Drag(MouseButton::Right),
1195 &KeyModifiers {
1196 shift: true,
1197 ..KeyModifiers::NONE
1198 },
1199 &MouseEventKind::Drag(MouseButton::Right),
1200 &KeyModifiers {
1201 shift: true,
1202 ..KeyModifiers::NONE
1203 },
1204 ));
1205 }
1206
1207 #[test]
1208 fn reject_drag_different_button() {
1209 assert!(!is_coalescable_mouse(
1210 &MouseEventKind::Drag(MouseButton::Left),
1211 &KeyModifiers::NONE,
1212 &MouseEventKind::Drag(MouseButton::Right),
1213 &KeyModifiers::NONE,
1214 ));
1215 }
1216
1217 #[test]
1218 fn reject_moved_vs_drag() {
1219 assert!(!is_coalescable_mouse(
1220 &MouseEventKind::Moved,
1221 &KeyModifiers::NONE,
1222 &MouseEventKind::Drag(MouseButton::Left),
1223 &KeyModifiers::NONE,
1224 ));
1225 }
1226
1227 #[test]
1228 fn reject_different_modifiers() {
1229 assert!(!is_coalescable_mouse(
1230 &MouseEventKind::Moved,
1231 &KeyModifiers::NONE,
1232 &MouseEventKind::Moved,
1233 &KeyModifiers {
1234 shift: true,
1235 ..KeyModifiers::NONE
1236 },
1237 ));
1238 assert!(!is_coalescable_mouse(
1239 &MouseEventKind::Drag(MouseButton::Left),
1240 &KeyModifiers {
1241 control: true,
1242 ..KeyModifiers::NONE
1243 },
1244 &MouseEventKind::Drag(MouseButton::Left),
1245 &KeyModifiers::NONE,
1246 ));
1247 }
1248
1249 #[test]
1250 fn reject_discrete_events() {
1251 assert!(!is_coalescable_mouse(
1252 &MouseEventKind::Press(MouseButton::Left),
1253 &KeyModifiers::NONE,
1254 &MouseEventKind::Press(MouseButton::Left),
1255 &KeyModifiers::NONE,
1256 ));
1257 assert!(!is_coalescable_mouse(
1258 &MouseEventKind::Release(MouseButton::Left),
1259 &KeyModifiers::NONE,
1260 &MouseEventKind::Moved,
1261 &KeyModifiers::NONE,
1262 ));
1263 assert!(!is_coalescable_mouse(
1264 &MouseEventKind::Moved,
1265 &KeyModifiers::NONE,
1266 &MouseEventKind::ScrollDown,
1267 &KeyModifiers::NONE,
1268 ));
1269 assert!(!is_coalescable_mouse(
1270 &MouseEventKind::ScrollUp,
1271 &KeyModifiers::NONE,
1272 &MouseEventKind::ScrollUp,
1273 &KeyModifiers::NONE,
1274 ));
1275 }
1276
1277 fn coalesce_through(
1282 events: &[Event],
1283 kind: MouseEventKind,
1284 modifiers: KeyModifiers,
1285 ) -> Option<Event> {
1286 let (tx, rx) = crossbeam_channel::bounded::<Event>(events.len());
1287 for e in events.iter().cloned() {
1288 tx.send(e).ok();
1289 }
1290 drop(tx);
1291
1292 let mut result = Event::Mouse(MouseEvent {
1293 kind,
1294 modifiers,
1295 column: 0,
1296 row: 0,
1297 });
1298
1299 if let Event::Mouse(ref mut mouse) = result
1300 && matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Drag(_))
1301 {
1302 while let Ok(next) = rx.try_recv() {
1303 match next {
1304 Event::Mouse(ref next_mouse)
1305 if is_coalescable_mouse(
1306 &mouse.kind,
1307 &mouse.modifiers,
1308 &next_mouse.kind,
1309 &next_mouse.modifiers,
1310 ) =>
1311 {
1312 *mouse = *next_mouse;
1313 }
1314 _other => return Some(result),
1315 }
1316 }
1317 }
1318
1319 Some(result)
1320 }
1321
1322 #[test]
1323 fn coalesce_keeps_latest_moved_position() {
1324 let events = vec![
1325 Event::Mouse(MouseEvent {
1326 kind: MouseEventKind::Moved,
1327 modifiers: KeyModifiers::NONE,
1328 column: 5,
1329 row: 5,
1330 }),
1331 Event::Mouse(MouseEvent {
1332 kind: MouseEventKind::Moved,
1333 modifiers: KeyModifiers::NONE,
1334 column: 10,
1335 row: 10,
1336 }),
1337 Event::Mouse(MouseEvent {
1338 kind: MouseEventKind::Moved,
1339 modifiers: KeyModifiers::NONE,
1340 column: 15,
1341 row: 15,
1342 }),
1343 ];
1344 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1345 let Event::Mouse(m) = result.unwrap() else {
1346 panic!("expected mouse")
1347 };
1348 assert_eq!((m.column, m.row), (15, 15));
1349 }
1350
1351 #[test]
1352 fn coalesce_keeps_latest_drag_position() {
1353 let events = vec![
1354 Event::Mouse(MouseEvent {
1355 kind: MouseEventKind::Drag(MouseButton::Left),
1356 modifiers: KeyModifiers::NONE,
1357 column: 1,
1358 row: 1,
1359 }),
1360 Event::Mouse(MouseEvent {
1361 kind: MouseEventKind::Drag(MouseButton::Left),
1362 modifiers: KeyModifiers::NONE,
1363 column: 2,
1364 row: 2,
1365 }),
1366 ];
1367 let result = coalesce_through(
1368 &events,
1369 MouseEventKind::Drag(MouseButton::Left),
1370 KeyModifiers::NONE,
1371 );
1372 let Event::Mouse(m) = result.unwrap() else {
1373 panic!("expected mouse")
1374 };
1375 assert_eq!((m.column, m.row), (2, 2));
1376 }
1377
1378 #[test]
1379 fn coalesce_stops_at_modifier_change() {
1380 let events = vec![Event::Mouse(MouseEvent {
1381 kind: MouseEventKind::Moved,
1382 modifiers: KeyModifiers {
1383 shift: true,
1384 ..KeyModifiers::NONE
1385 },
1386 column: 99,
1387 row: 99,
1388 })];
1389 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1390 let Event::Mouse(m) = result.unwrap() else {
1391 panic!("expected mouse")
1392 };
1393 assert_eq!((m.column, m.row), (0, 0));
1396 }
1397
1398 #[test]
1399 fn coalesce_stops_at_non_mouse_event() {
1400 let key = Event::Key(KeyEvent {
1401 code: KeyCode::Char('q'),
1402 kind: KeyKind::Press,
1403 modifiers: KeyModifiers::NONE,
1404 });
1405 let events = vec![key.clone()];
1406 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1407 let Event::Mouse(m) = result.unwrap() else {
1408 panic!("expected mouse")
1409 };
1410 assert_eq!((m.column, m.row), (0, 0));
1412 }
1413
1414 #[test]
1415 fn coalesce_stops_at_discrete_mouse_event() {
1416 let events = vec![Event::Mouse(MouseEvent {
1417 kind: MouseEventKind::Press(MouseButton::Left),
1418 modifiers: KeyModifiers::NONE,
1419 column: 10,
1420 row: 10,
1421 })];
1422 let result = coalesce_through(&events, MouseEventKind::Moved, KeyModifiers::NONE);
1423 let Event::Mouse(m) = result.unwrap() else {
1424 panic!("expected mouse")
1425 };
1426 assert_eq!((m.column, m.row), (0, 0));
1428 }
1429}
1430
1431#[cfg(test)]
1435#[allow(clippy::type_complexity)]
1436mod snapshot_tests {
1437 use super::*;
1438
1439 fn render_and_capture(pty_bytes: &[u8], rows: u16, cols: u16, clear_display: bool) -> Vec<u8> {
1442 let rt = tokio::runtime::Builder::new_current_thread()
1443 .build()
1444 .expect("tokio rt");
1445 let (push_tx, push_rx) = crossbeam_channel::bounded(16);
1446 let input_writer: Box<dyn FnMut(&[u8]) -> io::Result<()> + Send> = Box::new(|_| Ok(()));
1447 let mut pane = RemotePane::new(
1448 0,
1449 None,
1450 rt.handle().clone(),
1451 cols,
1452 rows,
1453 push_rx,
1454 input_writer,
1455 );
1456 drop(rt); push_tx.send(pty_bytes.to_vec()).ok();
1459 pane.drain_pushes();
1460
1461 let parser = pane.shared_parser();
1462 let parser = parser.lock().unwrap();
1463 let screen = parser.screen();
1464 let (rows, cols) = screen.size();
1465 let mut out = Vec::new();
1466 render_frame(&mut out, screen, rows, cols, clear_display).unwrap();
1467 out
1468 }
1469
1470 fn escape_ansi(bytes: &[u8]) -> String {
1472 let mut out: Vec<u8> = Vec::with_capacity(bytes.len() * 4);
1473 for &b in bytes {
1474 match b {
1475 b'\x1b' => out.extend_from_slice(b"\\x1b"),
1476 b'\n' => out.extend_from_slice(b"\\n"),
1477 b'\r' => out.extend_from_slice(b"\\r"),
1478 b'\t' => out.extend_from_slice(b"\\t"),
1479 0x20..=0x7e => out.push(b),
1480 _ => {
1481 out.push(b'\\');
1482 out.push(b'x');
1483 out.extend_from_slice(&hex_byte(b));
1484 }
1485 }
1486 }
1487 unsafe { String::from_utf8_unchecked(out) }
1489 }
1490
1491 fn hex_byte(b: u8) -> [u8; 2] {
1492 #[inline]
1493 fn hex_nibble(n: u8) -> u8 {
1494 let digit = n & 0x0f;
1495 if digit < 10 {
1496 b'0' + digit
1497 } else {
1498 b'a' + digit - 10
1499 }
1500 }
1501 [hex_nibble(b >> 4), hex_nibble(b)]
1502 }
1503
1504 #[test]
1507 fn snapshot_empty_grid() {
1508 let out = render_and_capture(b"", 4, 8, false);
1509 insta::assert_snapshot!("empty_grid", escape_ansi(&out));
1510 }
1511
1512 #[test]
1513 fn snapshot_basic_text() {
1514 let out = render_and_capture(b"Hello\nWorld", 4, 8, false);
1515 insta::assert_snapshot!("basic_text", escape_ansi(&out));
1516 }
1517
1518 #[test]
1519 fn snapshot_colored_text() {
1520 let out = render_and_capture(b"\x1b[31mred\x1b[1mbold", 4, 8, false);
1521 insta::assert_snapshot!("colored_text", escape_ansi(&out));
1522 }
1523
1524 #[test]
1525 fn snapshot_normal_char_at_margin() {
1526 let out = render_and_capture(b"ABCD", 1, 4, false);
1529 insta::assert_snapshot!("normal_char_at_margin", escape_ansi(&out));
1530 }
1531
1532 #[test]
1533 fn snapshot_clear_display() {
1534 let out = render_and_capture(b"", 4, 8, true);
1535 insta::assert_snapshot!("clear_display", escape_ansi(&out));
1536 }
1537
1538 #[test]
1539 fn snapshot_hidden_cursor() {
1540 let out = render_and_capture(b"\x1b[?25l", 4, 8, false);
1541 insta::assert_snapshot!("hidden_cursor", escape_ansi(&out));
1542 }
1543
1544 #[test]
1545 fn snapshot_color_across_margin() {
1546 let out = render_and_capture(b"\x1b[41mX", 1, 4, false);
1549 insta::assert_snapshot!("color_across_margin", escape_ansi(&out));
1550 }
1551
1552 #[test]
1553 fn snapshot_multi_row_fill() {
1554 let out = render_and_capture(b"ABCDEFGHIJKL", 3, 4, false);
1557 insta::assert_snapshot!("multi_row_fill", escape_ansi(&out));
1558 }
1559
1560 #[test]
1561 fn snapshot_wide_char_margin() {
1562 let out = render_and_capture(
1565 b"B\xe3\x81\x82", 1,
1567 3,
1568 false,
1569 );
1570 insta::assert_snapshot!("wide_char_margin", escape_ansi(&out));
1571 }
1572
1573 #[test]
1574 fn snapshot_wide_char_middle() {
1575 let out = render_and_capture(
1579 b"A\xe3\x81\x82\xe3\x81\x83", 1,
1581 5,
1582 false,
1583 );
1584 insta::assert_snapshot!("wide_char_middle", escape_ansi(&out));
1585 }
1586}