Skip to main content

term_session_client/
lib.rs

1mod remote_pane;
2
3pub use remote_pane::RemotePane;
4
5use std::io::{self, Write, stdout};
6use std::sync::Arc;
7use std::time::Duration;
8
9use crossterm::QueueableCommand;
10use crossterm::cursor::{Hide, Show};
11use crossterm::event as crossterm_event;
12use crossterm::terminal::{
13    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
14};
15use muxio_tokio_mpsc_adapter::ChannelCallerExt;
16use muxio_tokio_rpc_ipc_client::{RpcCallPrebuffered, RpcIpcClient, RpcServiceCallerInterface};
17use portable_pty::PtySize;
18use term_session_muxio_service_definitions::{
19    STREAM_INPUT_METHOD_ID, SUBSCRIBE_OUTPUT_METHOD_ID, Spawn,
20};
21use term_wm_core::events::{Event, KeyKind};
22use term_wm_pty_engine::Pane;
23use term_wm_pty_engine::clipboard::{Clipboard, extract_osc52_text};
24use term_wm_pty_engine::input_encoding::{key_to_bytes, mouse_event_to_bytes};
25use term_wm_pty_engine::signal::install_sigint_handler;
26use tokio::sync::mpsc;
27use vt100::{MouseProtocolEncoding, MouseProtocolMode};
28
29/// Number of iterations to wait for initial PTY output.
30/// Windows ConPTY needs more time to initialize and flush its internal buffers.
31#[cfg(target_os = "windows")]
32const INITIAL_WAIT_ITERS: usize = 60;
33#[cfg(not(target_os = "windows"))]
34const INITIAL_WAIT_ITERS: usize = 20;
35
36struct TerminalGuard;
37
38impl Drop for TerminalGuard {
39    fn drop(&mut self) {
40        let _ = stdout().queue(crossterm::event::DisableMouseCapture);
41        let _ = stdout().queue(Show);
42        let _ = stdout().queue(LeaveAlternateScreen);
43        let _ = disable_raw_mode();
44        let _ = stdout().flush();
45    }
46}
47
48/// Connect to a term-session-server and run the TUI viewer.
49///
50/// This function is synchronous. It creates a background tokio runtime
51/// for muxio IPC, then runs the synchronous crossterm event loop on the
52/// calling thread.
53pub fn run_session(socket_path: &str) -> io::Result<()> {
54    let rt =
55        tokio::runtime::Runtime::new().map_err(|e| io::Error::other(format!("runtime: {e}")))?;
56
57    // Connect via muxio IPC
58    let client: Arc<RpcIpcClient> = rt
59        .block_on(RpcIpcClient::new(socket_path))
60        .map_err(|e| io::Error::new(io::ErrorKind::ConnectionRefused, format!("{e:?}")))?;
61
62    // Channel for raw PTY output bytes from the subscription stream
63    let (push_tx, push_rx) = mpsc::unbounded_channel::<Vec<u8>>();
64
65    // Get terminal size
66    let (term_cols, term_rows) = crossterm::terminal::size()?;
67
68    // Spawn session on the server
69    rt.block_on(async {
70        Spawn::call(&*client, (None, term_cols, term_rows))
71            .await
72            .map_err(|e| io::Error::other(format!("spawn: {e:?}")))
73    })?;
74
75    // Open streaming channels for output subscription and input
76    let writer = rt.block_on(async {
77        // Subscribe to PTY output via the mpsc adapter.
78        // `reader` yields response chunks (raw PTY output bytes).
79        let (_, mut reader) = client
80            .open_channel(SUBSCRIBE_OUTPUT_METHOD_ID, 0)
81            .await
82            .map_err(|e| io::Error::other(format!("subscribe: {e:?}")))?;
83
84        // Forward response chunks to push_tx.  When the stream ends
85        // (session exits), push_tx is dropped, and `drain_pushes`
86        // detects the disconnect.
87        rt.spawn(async move {
88            while let Some(chunk) = reader.recv().await {
89                match chunk {
90                    Ok(data) => {
91                        let _ = push_tx.send(data);
92                    }
93                    Err(_) => break,
94                }
95            }
96        });
97
98        // Open streaming channel for PTY input.
99        // `writer` accepts keystroke bytes.
100        let (writer, _) = client
101            .open_channel(STREAM_INPUT_METHOD_ID, 0)
102            .await
103            .map_err(|e| io::Error::other(format!("stream input: {e:?}")))?;
104
105        Ok::<_, io::Error>(writer)
106    })?;
107
108    let input_writer = Box::new(move |data: &[u8]| -> io::Result<()> {
109        writer
110            .send(data.to_vec())
111            .map_err(|e| io::Error::other(e.to_string()))?;
112        Ok(())
113    });
114
115    let mut pane = RemotePane::new(
116        1u64,
117        client.clone(),
118        rt.handle().clone(),
119        term_cols,
120        term_rows,
121        push_rx,
122        input_writer,
123    );
124
125    // Wait for initial output
126    for _ in 0..INITIAL_WAIT_ITERS {
127        pane.drain_pushes();
128        let parser = pane.shared_parser();
129        let parser = parser.lock().unwrap();
130        if !parser.screen().contents_formatted().is_empty() {
131            break;
132        }
133        drop(parser);
134        std::thread::sleep(Duration::from_millis(50));
135    }
136
137    enable_raw_mode()?;
138    let mut out = stdout();
139    out.queue(EnterAlternateScreen)?;
140    out.queue(Hide)?;
141    out.queue(crossterm::event::EnableMouseCapture)?;
142    out.flush()?;
143    let _guard = TerminalGuard;
144
145    let mut clipboard = Clipboard::new();
146    let sigint = install_sigint_handler()?;
147
148    // Initial full screen render
149    {
150        let parser = pane.shared_parser();
151        let parser = parser.lock().unwrap();
152        let screen = parser.screen();
153        let data = screen.contents_formatted();
154        out.write_all(&data)?;
155        out.flush()?;
156    }
157
158    let mut prev_content: Option<Vec<u8>> = None;
159
160    loop {
161        let frame_start = std::time::Instant::now();
162
163        // Drain pushes from the server (this updates the parser)
164        pane.drain_pushes();
165
166        // Detect screen changes
167        let current_content = {
168            let parser = pane.shared_parser();
169            let parser = parser.lock().unwrap();
170            parser.screen().contents_formatted()
171        };
172        let has_new_data = prev_content.as_deref() != Some(&current_content);
173
174        // Process OSC 52 clipboard data
175        if has_new_data && let Some(text) = extract_osc52_text(&current_content) {
176            let _ = clipboard.set(&text);
177        }
178
179        // Check connection health
180        if !client.is_connected() {
181            return Err(io::Error::other("connection to session server lost"));
182        }
183
184        // Drain SIGINT (Ctrl-C) — send 0x03 through the input stream
185        if sigint.received() {
186            sigint.ack();
187            let _ = pane.write_bytes(&[0x03]);
188        }
189
190        // Poll input with a short timeout
191        let had_input = if crossterm_event::poll(Duration::from_millis(4))? {
192            let crossterm_evt = crossterm_event::read()?;
193            // Convert crossterm event to core-owned event
194            let evt = match crossterm_evt {
195                crossterm_event::Event::Key(key) => Event::Key(term_wm_core::events::KeyEvent {
196                    code: match key.code {
197                        crossterm_event::KeyCode::Char(c) => term_wm_core::events::KeyCode::Char(c),
198                        crossterm_event::KeyCode::Enter => term_wm_core::events::KeyCode::Enter,
199                        crossterm_event::KeyCode::Tab => term_wm_core::events::KeyCode::Tab,
200                        crossterm_event::KeyCode::Backspace => {
201                            term_wm_core::events::KeyCode::Backspace
202                        }
203                        crossterm_event::KeyCode::Esc => term_wm_core::events::KeyCode::Esc,
204                        crossterm_event::KeyCode::Left => term_wm_core::events::KeyCode::Left,
205                        crossterm_event::KeyCode::Right => term_wm_core::events::KeyCode::Right,
206                        crossterm_event::KeyCode::Up => term_wm_core::events::KeyCode::Up,
207                        crossterm_event::KeyCode::Down => term_wm_core::events::KeyCode::Down,
208                        crossterm_event::KeyCode::Home => term_wm_core::events::KeyCode::Home,
209                        crossterm_event::KeyCode::End => term_wm_core::events::KeyCode::End,
210                        crossterm_event::KeyCode::PageUp => term_wm_core::events::KeyCode::PageUp,
211                        crossterm_event::KeyCode::PageDown => {
212                            term_wm_core::events::KeyCode::PageDown
213                        }
214                        crossterm_event::KeyCode::Delete => term_wm_core::events::KeyCode::Delete,
215                        crossterm_event::KeyCode::Insert => term_wm_core::events::KeyCode::Insert,
216                        crossterm_event::KeyCode::F(n) => term_wm_core::events::KeyCode::F(n),
217                        _ => continue,
218                    },
219                    modifiers: term_wm_core::events::KeyModifiers {
220                        shift: key.modifiers.contains(crossterm_event::KeyModifiers::SHIFT),
221                        control: key
222                            .modifiers
223                            .contains(crossterm_event::KeyModifiers::CONTROL),
224                        alt: key.modifiers.contains(crossterm_event::KeyModifiers::ALT),
225                    },
226                    kind: match key.kind {
227                        crossterm_event::KeyEventKind::Press => KeyKind::Press,
228                        crossterm_event::KeyEventKind::Repeat => KeyKind::Repeat,
229                        crossterm_event::KeyEventKind::Release => KeyKind::Release,
230                    },
231                }),
232                crossterm_event::Event::Mouse(mouse) => {
233                    Event::Mouse(term_wm_core::events::MouseEvent {
234                        kind: match mouse.kind {
235                            crossterm_event::MouseEventKind::Down(btn) => {
236                                term_wm_core::events::MouseEventKind::Press(match btn {
237                                    crossterm_event::MouseButton::Left => {
238                                        term_wm_core::events::MouseButton::Left
239                                    }
240                                    crossterm_event::MouseButton::Right => {
241                                        term_wm_core::events::MouseButton::Right
242                                    }
243                                    crossterm_event::MouseButton::Middle => {
244                                        term_wm_core::events::MouseButton::Middle
245                                    }
246                                })
247                            }
248                            crossterm_event::MouseEventKind::Up(btn) => {
249                                term_wm_core::events::MouseEventKind::Release(match btn {
250                                    crossterm_event::MouseButton::Left => {
251                                        term_wm_core::events::MouseButton::Left
252                                    }
253                                    crossterm_event::MouseButton::Right => {
254                                        term_wm_core::events::MouseButton::Right
255                                    }
256                                    crossterm_event::MouseButton::Middle => {
257                                        term_wm_core::events::MouseButton::Middle
258                                    }
259                                })
260                            }
261                            crossterm_event::MouseEventKind::Drag(btn) => {
262                                term_wm_core::events::MouseEventKind::Drag(match btn {
263                                    crossterm_event::MouseButton::Left => {
264                                        term_wm_core::events::MouseButton::Left
265                                    }
266                                    crossterm_event::MouseButton::Right => {
267                                        term_wm_core::events::MouseButton::Right
268                                    }
269                                    crossterm_event::MouseButton::Middle => {
270                                        term_wm_core::events::MouseButton::Middle
271                                    }
272                                })
273                            }
274                            crossterm_event::MouseEventKind::Moved => {
275                                term_wm_core::events::MouseEventKind::Moved
276                            }
277                            crossterm_event::MouseEventKind::ScrollUp => {
278                                term_wm_core::events::MouseEventKind::ScrollUp
279                            }
280                            crossterm_event::MouseEventKind::ScrollDown => {
281                                term_wm_core::events::MouseEventKind::ScrollDown
282                            }
283                            crossterm_event::MouseEventKind::ScrollLeft => {
284                                term_wm_core::events::MouseEventKind::ScrollLeft
285                            }
286                            crossterm_event::MouseEventKind::ScrollRight => {
287                                term_wm_core::events::MouseEventKind::ScrollRight
288                            }
289                        },
290                        modifiers: term_wm_core::events::KeyModifiers {
291                            shift: mouse
292                                .modifiers
293                                .contains(crossterm_event::KeyModifiers::SHIFT),
294                            control: mouse
295                                .modifiers
296                                .contains(crossterm_event::KeyModifiers::CONTROL),
297                            alt: mouse.modifiers.contains(crossterm_event::KeyModifiers::ALT),
298                        },
299                        column: mouse.column,
300                        row: mouse.row,
301                    })
302                }
303                crossterm_event::Event::Resize(w, h) => Event::Resize(w, h),
304                crossterm_event::Event::FocusGained => Event::FocusGained,
305                crossterm_event::Event::FocusLost => Event::FocusLost,
306                crossterm_event::Event::Paste(text) => Event::Paste(text),
307            };
308            match evt {
309                Event::Key(ref key)
310                    if key.kind == KeyKind::Press || key.kind == KeyKind::Repeat =>
311                {
312                    // Convert core-owned KeyEvent to pty-engine KeyEvent
313                    let pty_key = term_wm_pty_engine::input_encoding::KeyEvent {
314                        code: match key.code {
315                            term_wm_core::events::KeyCode::Char(c) => {
316                                term_wm_pty_engine::input_encoding::KeyCode::Char(c)
317                            }
318                            term_wm_core::events::KeyCode::Enter => {
319                                term_wm_pty_engine::input_encoding::KeyCode::Enter
320                            }
321                            term_wm_core::events::KeyCode::Tab => {
322                                term_wm_pty_engine::input_encoding::KeyCode::Tab
323                            }
324                            term_wm_core::events::KeyCode::Backspace => {
325                                term_wm_pty_engine::input_encoding::KeyCode::Backspace
326                            }
327                            term_wm_core::events::KeyCode::Esc => {
328                                term_wm_pty_engine::input_encoding::KeyCode::Esc
329                            }
330                            term_wm_core::events::KeyCode::Left => {
331                                term_wm_pty_engine::input_encoding::KeyCode::Left
332                            }
333                            term_wm_core::events::KeyCode::Right => {
334                                term_wm_pty_engine::input_encoding::KeyCode::Right
335                            }
336                            term_wm_core::events::KeyCode::Up => {
337                                term_wm_pty_engine::input_encoding::KeyCode::Up
338                            }
339                            term_wm_core::events::KeyCode::Down => {
340                                term_wm_pty_engine::input_encoding::KeyCode::Down
341                            }
342                            term_wm_core::events::KeyCode::Home => {
343                                term_wm_pty_engine::input_encoding::KeyCode::Home
344                            }
345                            term_wm_core::events::KeyCode::End => {
346                                term_wm_pty_engine::input_encoding::KeyCode::End
347                            }
348                            term_wm_core::events::KeyCode::PageUp => {
349                                term_wm_pty_engine::input_encoding::KeyCode::PageUp
350                            }
351                            term_wm_core::events::KeyCode::PageDown => {
352                                term_wm_pty_engine::input_encoding::KeyCode::PageDown
353                            }
354                            term_wm_core::events::KeyCode::Delete => {
355                                term_wm_pty_engine::input_encoding::KeyCode::Delete
356                            }
357                            term_wm_core::events::KeyCode::Insert => {
358                                term_wm_pty_engine::input_encoding::KeyCode::Insert
359                            }
360                            term_wm_core::events::KeyCode::F(n) => {
361                                term_wm_pty_engine::input_encoding::KeyCode::F(n)
362                            }
363                            _ => continue,
364                        },
365                        modifiers: term_wm_pty_engine::input_encoding::KeyModifiers {
366                            shift: key.modifiers.shift,
367                            control: key.modifiers.control,
368                            alt: key.modifiers.alt,
369                        },
370                    };
371                    let bytes = key_to_bytes(&pty_key);
372                    if !bytes.is_empty() {
373                        let _ = pane.write_bytes(&bytes);
374                    }
375                    true
376                }
377                Event::Mouse(ref mouse) => {
378                    let mouse_active = {
379                        let parser = pane.shared_parser();
380                        let parser = parser.lock().unwrap();
381                        parser.screen().mouse_protocol_mode() != MouseProtocolMode::None
382                    };
383                    if mouse_active {
384                        // Convert core-owned MouseEvent to pty-engine MouseEvent
385                        let pty_mouse = term_wm_pty_engine::input_encoding::MouseEvent {
386                            kind: match mouse.kind {
387                                term_wm_core::events::MouseEventKind::Press(btn) => term_wm_pty_engine::input_encoding::MouseEventKind::Press(match btn {
388                                    term_wm_core::events::MouseButton::Left => term_wm_pty_engine::input_encoding::MouseButton::Left,
389                                    term_wm_core::events::MouseButton::Right => term_wm_pty_engine::input_encoding::MouseButton::Right,
390                                    term_wm_core::events::MouseButton::Middle => term_wm_pty_engine::input_encoding::MouseButton::Middle,
391                                }),
392                                term_wm_core::events::MouseEventKind::Release(btn) => term_wm_pty_engine::input_encoding::MouseEventKind::Release(match btn {
393                                    term_wm_core::events::MouseButton::Left => term_wm_pty_engine::input_encoding::MouseButton::Left,
394                                    term_wm_core::events::MouseButton::Right => term_wm_pty_engine::input_encoding::MouseButton::Right,
395                                    term_wm_core::events::MouseButton::Middle => term_wm_pty_engine::input_encoding::MouseButton::Middle,
396                                }),
397                                term_wm_core::events::MouseEventKind::Drag(btn) => term_wm_pty_engine::input_encoding::MouseEventKind::Drag(match btn {
398                                    term_wm_core::events::MouseButton::Left => term_wm_pty_engine::input_encoding::MouseButton::Left,
399                                    term_wm_core::events::MouseButton::Right => term_wm_pty_engine::input_encoding::MouseButton::Right,
400                                    term_wm_core::events::MouseButton::Middle => term_wm_pty_engine::input_encoding::MouseButton::Middle,
401                                }),
402                                term_wm_core::events::MouseEventKind::Moved => term_wm_pty_engine::input_encoding::MouseEventKind::Moved,
403                                term_wm_core::events::MouseEventKind::ScrollUp => term_wm_pty_engine::input_encoding::MouseEventKind::ScrollUp,
404                                term_wm_core::events::MouseEventKind::ScrollDown => term_wm_pty_engine::input_encoding::MouseEventKind::ScrollDown,
405                                term_wm_core::events::MouseEventKind::ScrollLeft => term_wm_pty_engine::input_encoding::MouseEventKind::ScrollLeft,
406                                term_wm_core::events::MouseEventKind::ScrollRight => term_wm_pty_engine::input_encoding::MouseEventKind::ScrollRight,
407                            },
408                            modifiers: term_wm_pty_engine::input_encoding::KeyModifiers {
409                                shift: mouse.modifiers.shift,
410                                control: mouse.modifiers.control,
411                                alt: mouse.modifiers.alt,
412                            },
413                            column: mouse.column,
414                            row: mouse.row,
415                        };
416                        let bytes = mouse_event_to_bytes(&pty_mouse, MouseProtocolEncoding::Sgr);
417                        if !bytes.is_empty() {
418                            let _ = pane.write_bytes(&bytes);
419                        }
420                    }
421                    mouse_active
422                }
423                Event::Resize(w, h) => {
424                    let size = PtySize {
425                        rows: h,
426                        cols: w,
427                        pixel_width: 0,
428                        pixel_height: 0,
429                    };
430                    prev_content = None;
431                    let _ = pane.resize(size);
432                    true
433                }
434                _ => false,
435            }
436        } else {
437            false
438        };
439
440        // Diff-based incremental render
441        if has_new_data || prev_content.is_none() {
442            let parser = pane.shared_parser();
443            let parser = parser.lock().unwrap();
444            let screen = parser.screen();
445            let (rows, cols) = screen.size();
446
447            let diff = match &prev_content {
448                Some(prev) => {
449                    let mut prev_parser = vt100::Parser::new(rows, cols, 0);
450                    prev_parser.process(prev);
451                    screen.contents_diff(prev_parser.screen())
452                }
453                None => screen.contents_formatted(),
454            };
455
456            if !diff.is_empty() {
457                out.write_all(&diff)?;
458                out.flush()?;
459            }
460
461            prev_content = Some(screen.contents_formatted());
462        }
463
464        // Exit on session exit
465        if pane.has_exited() {
466            return Ok(());
467        }
468
469        // Pace the loop
470        if !has_new_data && !had_input {
471            let elapsed = frame_start.elapsed();
472            if elapsed < Duration::from_millis(8) {
473                std::thread::sleep(Duration::from_millis(8) - elapsed);
474            }
475        }
476    }
477}