Skip to main content
Glama
session.rs25.9 kB
//! Terminal session management. use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; use tracing::{debug, error, info, warn}; use terminal_mcp_core::{Dimensions, Key, Result, SessionId}; use terminal_mcp_detector::DetectionPipeline; use terminal_mcp_emulator::{Grid, Parser, PtyHandle, SessionRecorder}; use crate::navigation::NavigationCalculator; use crate::output::OutputBuffer; use crate::snapshot::SnapshotConfig; use crate::visual::{SessionMode, VisualTerminalHandle}; /// Status of a terminal session. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionStatus { /// Session is running Running, /// Session has exited normally Exited, /// Session was terminated Terminated, } /// A terminal session. #[derive(Debug)] pub struct Session { /// Session identifier id: SessionId, /// PTY handle pty: Arc<Mutex<PtyHandle>>, /// Terminal grid and parser parser: Arc<Mutex<Parser>>, /// Output buffer for raw terminal output pub(crate) output_buf: Arc<Mutex<OutputBuffer>>, /// Optional session recorder recorder: Arc<Mutex<Option<SessionRecorder>>>, /// Command that was executed command: String, /// Command arguments args: Vec<String>, /// Session creation time created_at: SystemTime, /// Current session status status: Arc<Mutex<SessionStatus>>, /// Session mode (headless or visual) mode: SessionMode, /// Visual terminal handle (only for visual mode) visual_handle: Option<VisualTerminalHandle>, } impl Session { /// Create a new session in headless mode (backward compatible). pub fn create(command: String, args: Vec<String>, dimensions: Dimensions) -> Result<Self> { Self::create_with_mode(command, args, dimensions, SessionMode::Headless, None) } /// Create a new session with specified mode and optional terminal emulator. pub fn create_with_mode( command: String, args: Vec<String>, dimensions: Dimensions, mode: SessionMode, terminal_emulator: Option<String>, ) -> Result<Self> { info!( "Creating session: command='{}', mode={:?}, dimensions={}x{}, emulator={:?}", command, mode, dimensions.rows, dimensions.cols, terminal_emulator ); // In visual mode, spawn terminal connected via tmux for proper I/O control let (visual_handle, pty) = if mode == SessionMode::Visual { debug!("Creating visual mode session with tmux"); // Generate unique tmux session name let session_name = format!("terminal-mcp-{}", uuid::Uuid::new_v4()); // Build command string let full_command = if args.is_empty() { command.clone() } else { format!("{} {}", command, args.join(" ")) }; // Create tmux session (detached) use std::process::Command as StdCommand; let tmux_result = StdCommand::new("tmux") .arg("new-session") .arg("-d") .arg("-s") .arg(&session_name) .arg("-x") .arg(dimensions.cols.to_string()) .arg("-y") .arg(dimensions.rows.to_string()) .arg("bash") .arg("-c") .arg(&full_command) .status(); match tmux_result { Ok(status) if status.success() => { info!("Tmux session created: {}", session_name); // Spawn visual terminal attached to tmux session let term_name = terminal_emulator.as_deref().unwrap_or("xterm"); // Launch visual terminal attached to tmux // Inherit all environment variables (including DISPLAY for WSLg) let visual_cmd = StdCommand::new(term_name) .arg("-e") .arg("tmux") .arg("attach-session") .arg("-t") .arg(&session_name) .spawn(); if let Err(ref e) = visual_cmd { error!("Failed to spawn xterm: {:?}", e); } let handle = if let Ok(child) = visual_cmd { info!( "Visual terminal spawned: {} (pid: {})", term_name, child.id() ); Some(VisualTerminalHandle::with_window_id( child.id(), term_name, session_name.clone(), )) } else { warn!("Failed to spawn visual terminal: {}", term_name); None }; // Create PTY wrapper for tmux control let pty = PtyHandle::spawn_tmux(&session_name, dimensions)?; (handle, pty) } _ => { warn!("Tmux session creation failed, falling back to headless PTY"); // Fall back to regular PTY if tmux fails let pty = PtyHandle::spawn(&command, &args, dimensions)?; (None, pty) } } } else { debug!("Creating headless PTY session"); // Headless mode: regular PTY let pty = PtyHandle::spawn(&command, &args, dimensions)?; (None, pty) }; // Create grid and parser let grid = Grid::new(dimensions); let parser = Parser::new(grid); let session_id = SessionId::new(); info!( "Session created successfully: id={}, mode={:?}", session_id, mode ); Ok(Self { id: session_id, pty: Arc::new(Mutex::new(pty)), parser: Arc::new(Mutex::new(parser)), output_buf: Arc::new(Mutex::new(OutputBuffer::new())), recorder: Arc::new(Mutex::new(None)), command, args, created_at: SystemTime::now(), status: Arc::new(Mutex::new(SessionStatus::Running)), mode, visual_handle, }) } /// Get the session ID. pub fn id(&self) -> &SessionId { &self.id } /// Get the PTY handle. pub fn pty(&self) -> Arc<Mutex<PtyHandle>> { Arc::clone(&self.pty) } /// Get the parser (which contains the grid). pub fn parser(&self) -> Arc<Mutex<Parser>> { Arc::clone(&self.parser) } /// Get the command. pub fn command(&self) -> &str { &self.command } /// Get the command arguments. pub fn args(&self) -> &[String] { &self.args } /// Get the session creation time. pub fn created_at(&self) -> SystemTime { self.created_at } /// Get the current session status. pub fn status(&self) -> SessionStatus { *self.status.lock().unwrap() } /// Set the session status. pub fn set_status(&self, status: SessionStatus) { let old_status = *self.status.lock().unwrap(); *self.status.lock().unwrap() = status; info!( "Session status changed: id={}, {:?} → {:?}", self.id, old_status, status ); } /// Get the session mode. pub fn mode(&self) -> SessionMode { self.mode } /// Get the visual terminal handle. pub fn visual_handle(&self) -> Option<&VisualTerminalHandle> { self.visual_handle.as_ref() } /// Check if the session is alive. pub fn is_alive(&self) -> bool { let pty = self.pty.lock().unwrap(); pty.is_alive() } /// Process PTY output through the parser. /// /// Reads available output from the PTY and feeds it through the VTE parser /// to update the grid state. If recording is active, records the output. pub fn process_output(&self) -> Result<usize> { let pty = self.pty.lock().unwrap(); let bytes = pty.read()?; let count = bytes.len(); if count > 0 { debug!("Processing PTY output: id={}, {} bytes", self.id, count); // Save to output buffer let mut output_buf = self.output_buf.lock().unwrap(); output_buf.append(&bytes); drop(output_buf); // Record output if recording is active let mut recorder = self.recorder.lock().unwrap(); if let Some(rec) = recorder.as_mut() { rec.record_output(&bytes); } drop(recorder); // Process through parser let mut parser = self.parser.lock().unwrap(); parser.process(&bytes); } Ok(count) } /// Write bytes to the PTY. /// /// If recording is active, records the input. pub fn write(&self, data: &[u8]) -> Result<usize> { debug!("Writing to PTY: id={}, {} bytes", self.id, data.len()); // Record input if recording is active let mut recorder = self.recorder.lock().unwrap(); if let Some(rec) = recorder.as_mut() { rec.record_input(data); } drop(recorder); let pty = self.pty.lock().unwrap(); pty.write(data) } /// Resize the terminal. pub fn resize(&self, new_dimensions: Dimensions) -> Result<()> { info!( "Resizing session: id={}, {}x{}", self.id, new_dimensions.rows, new_dimensions.cols ); let pty = self.pty.lock().unwrap(); pty.resize(new_dimensions)?; let mut parser = self.parser.lock().unwrap(); parser.grid_mut().resize(new_dimensions); Ok(()) } /// Press a key in the terminal. /// /// Parses the key string and sends the corresponding escape sequence to the PTY. /// In visual mode (tmux), adds a small delay after sending to allow the /// application to process the key event. /// /// # Examples /// /// ``` /// # use terminal_mcp_session::Session; /// # use terminal_mcp_core::Dimensions; /// # let session = Session::create("bash".to_string(), vec![], Dimensions::new(24, 80)).unwrap(); /// session.press_key("Enter").unwrap(); /// session.press_key("Up").unwrap(); /// session.press_key("Ctrl+c").unwrap(); /// session.press_key("F5").unwrap(); /// ``` pub fn press_key(&self, key: &str) -> Result<()> { let key = Key::parse(key)?; let escape_sequence = key.to_escape_sequence(); self.write(&escape_sequence)?; // In visual mode, add a small delay to allow the TUI application // to process the key event before the next one is sent. // This fixes the issue where consecutive key events are batched // and only the first one is processed. if self.mode == SessionMode::Visual { std::thread::sleep(Duration::from_millis(10)); } Ok(()) } /// Type text into the terminal. /// /// Sends the text string to the PTY, optionally with a delay between each character. /// /// # Examples /// /// ``` /// # use terminal_mcp_session::Session; /// # use terminal_mcp_core::Dimensions; /// # let session = Session::create("bash".to_string(), vec![], Dimensions::new(24, 80)).unwrap(); /// // Type text immediately /// session.type_text("hello", None).unwrap(); /// /// // Type text with 10ms delay between characters /// session.type_text("hello", Some(10)).unwrap(); /// ``` pub fn type_text(&self, text: &str, delay_ms: Option<u64>) -> Result<usize> { if let Some(delay) = delay_ms { // Type character by character with delay let mut total = 0; for ch in text.chars() { self.write(ch.to_string().as_bytes())?; total += 1; if delay > 0 { std::thread::sleep(Duration::from_millis(delay)); } } Ok(total) } else { // Type all at once let bytes = text.as_bytes(); self.write(bytes)?; Ok(text.chars().count()) } } /// Click on an element by navigating to it and activating it. /// /// Takes a snapshot, calculates the keystrokes needed to reach and activate /// the target element, then sends those keystrokes with delays between them. /// /// # Arguments /// * `target_ref` - Reference ID of the element to click (e.g., "item_1", "button_0") /// * `pipeline` - Detection pipeline for taking snapshot /// * `snapshot_config` - Configuration for snapshot operations /// * `inter_key_delay_ms` - Optional delay between keystrokes in ms (default: 50ms) /// /// # Returns /// Vector of key names that were sent /// /// # Example /// ```no_run /// # use terminal_mcp_session::{Session, SnapshotConfig}; /// # use terminal_mcp_detector::DetectionPipeline; /// # use terminal_mcp_core::Dimensions; /// # let session = Session::create("bash".to_string(), vec![], Dimensions::new(24, 80)).unwrap(); /// let pipeline = DetectionPipeline::new(); /// let config = SnapshotConfig::default(); /// /// // Click on menu item_1 /// let keys = session.click("item_1", &pipeline, &config, None).unwrap(); /// println!("Sent keystrokes: {:?}", keys); /// ``` pub fn click( &self, target_ref: &str, pipeline: &DetectionPipeline, snapshot_config: &SnapshotConfig, inter_key_delay_ms: Option<u64>, ) -> Result<Vec<String>> { let delay = inter_key_delay_ms.unwrap_or(50); // Take snapshot to get current state let snapshot = self.snapshot(pipeline, snapshot_config)?; // Calculate navigation keystrokes let calculator = NavigationCalculator::new(); let keys = calculator.calculate(&snapshot, target_ref)?; // Send each keystroke with delay let mut key_names = Vec::new(); for (i, key) in keys.iter().enumerate() { // Convert Key to escape sequence and send let escape_seq = key.to_escape_sequence(); self.write(&escape_seq)?; // Store key name for response key_names.push(key.to_string()); // Add delay between keys (except after the last one) if i < keys.len() - 1 && delay > 0 { std::thread::sleep(Duration::from_millis(delay)); } } Ok(key_names) } /// Terminate the session. pub fn terminate(&self) -> Result<()> { info!("Terminating session: id={}", self.id); // Kill the visual terminal (xterm) if present if let Some(handle) = &self.visual_handle { info!("Killing visual terminal: pid={}", handle.pid); unsafe { // Send SIGTERM to the xterm process libc::kill(handle.pid as i32, libc::SIGTERM); } } // Kill the PTY/tmux session let pty = self.pty.lock().unwrap(); pty.kill().map_err(|e| { error!("Failed to kill PTY for session {}: {}", self.id, e); e })?; self.set_status(SessionStatus::Terminated); info!("Session terminated successfully: id={}", self.id); Ok(()) } /// Start recording the session. /// /// Creates a new SessionRecorder that will capture all terminal I/O /// for later playback in asciinema format. /// /// # Errors /// /// Returns an error if recording is already active. pub fn start_recording(&self) -> Result<()> { let mut recorder = self.recorder.lock().unwrap(); if recorder.is_some() { return Err(std::io::Error::new( std::io::ErrorKind::AlreadyExists, "Recording already in progress", ) .into()); } let dimensions = { let parser = self.parser.lock().unwrap(); parser.grid().dimensions() }; *recorder = Some(SessionRecorder::new(dimensions)); Ok(()) } /// Stop recording and return the recorder. /// /// Returns the SessionRecorder with all recorded events, or None if /// no recording was active. pub fn stop_recording(&self) -> Option<SessionRecorder> { let mut recorder = self.recorder.lock().unwrap(); recorder.take() } /// Check if recording is active. pub fn is_recording(&self) -> bool { self.recorder.lock().unwrap().is_some() } /// Save the current recording to a file. /// /// # Errors /// /// Returns an error if no recording is active or if file operations fail. pub fn save_recording<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> { let recorder = self.recorder.lock().unwrap(); match recorder.as_ref() { Some(rec) => rec.save_to_file(path), None => Err(std::io::Error::new( std::io::ErrorKind::NotFound, "No recording in progress", ) .into()), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_session_create() { let session = Session::create( "echo".to_string(), vec!["hello".to_string()], Dimensions::new(24, 80), ); assert!(session.is_ok()); let session = session.unwrap(); assert_eq!(session.command(), "echo"); assert_eq!(session.args(), &["hello"]); assert_eq!(session.status(), SessionStatus::Running); } #[test] fn test_session_id_unique() { let session1 = Session::create("echo".to_string(), vec![], Dimensions::new(24, 80)).unwrap(); let session2 = Session::create("echo".to_string(), vec![], Dimensions::new(24, 80)).unwrap(); assert_ne!(session1.id(), session2.id()); } #[test] fn test_session_process_output() { let session = Session::create( "echo".to_string(), vec!["hello".to_string()], Dimensions::new(24, 80), ) .unwrap(); // Allow some time for command to execute std::thread::sleep(std::time::Duration::from_millis(100)); // Process output let result = session.process_output(); assert!(result.is_ok()); } #[test] fn test_session_write() { let session = Session::create( if cfg!(windows) { "cmd.exe" } else { "sh" }.to_string(), vec![], Dimensions::new(24, 80), ) .unwrap(); let result = session.write(b"echo test\n"); assert!(result.is_ok()); assert!(result.unwrap() > 0); } #[test] fn test_session_resize() { let session = Session::create("echo".to_string(), vec![], Dimensions::new(24, 80)).unwrap(); let result = session.resize(Dimensions::new(30, 100)); assert!(result.is_ok()); } #[test] fn test_session_terminate() { let session = Session::create( if cfg!(windows) { "cmd.exe" } else { "sh" }.to_string(), vec![], Dimensions::new(24, 80), ) .unwrap(); assert_eq!(session.status(), SessionStatus::Running); let result = session.terminate(); assert!(result.is_ok()); assert_eq!(session.status(), SessionStatus::Terminated); } #[test] fn test_session_press_key() { let session = Session::create( if cfg!(windows) { "cmd.exe" } else { "sh" }.to_string(), vec![], Dimensions::new(24, 80), ) .unwrap(); // Test pressing Enter key let result = session.press_key("Enter"); assert!(result.is_ok()); // Test pressing arrow key let result = session.press_key("Up"); assert!(result.is_ok()); // Test pressing Ctrl+C let result = session.press_key("Ctrl+c"); assert!(result.is_ok()); // Test pressing function key let result = session.press_key("F5"); assert!(result.is_ok()); // Test invalid key let result = session.press_key("InvalidKey"); assert!(result.is_err()); } #[test] fn test_session_type_text() { let session = Session::create( if cfg!(windows) { "cmd.exe" } else { "sh" }.to_string(), vec![], Dimensions::new(24, 80), ) .unwrap(); // Test typing without delay let result = session.type_text("hello", None); assert!(result.is_ok()); assert_eq!(result.unwrap(), 5); // Test typing with delay let result = session.type_text("test", Some(10)); assert!(result.is_ok()); assert_eq!(result.unwrap(), 4); // Test empty string let result = session.type_text("", None); assert!(result.is_ok()); assert_eq!(result.unwrap(), 0); } #[test] fn test_session_press_key_escape_sequences() { let session = Session::create( if cfg!(windows) { "cmd.exe" } else { "sh" }.to_string(), vec![], Dimensions::new(24, 80), ) .unwrap(); // Test that navigation keys work assert!(session.press_key("Down").is_ok()); assert!(session.press_key("Left").is_ok()); assert!(session.press_key("Right").is_ok()); assert!(session.press_key("Home").is_ok()); assert!(session.press_key("End").is_ok()); assert!(session.press_key("PageUp").is_ok()); assert!(session.press_key("PageDown").is_ok()); // Test that special keys work assert!(session.press_key("Tab").is_ok()); assert!(session.press_key("Escape").is_ok()); assert!(session.press_key("Backspace").is_ok()); assert!(session.press_key("Delete").is_ok()); } #[test] fn test_session_click() { use crate::SnapshotConfig; use terminal_mcp_detector::DetectionPipeline; // Create session with a menu let _session = Session::create( "echo".to_string(), vec!["test".to_string()], Dimensions::new(24, 80), ) .unwrap(); let _pipeline = DetectionPipeline::new(); let _config = SnapshotConfig { idle_threshold: Duration::from_millis(50), idle_timeout: Duration::from_millis(500), ..Default::default() }; // Allow command to execute std::thread::sleep(Duration::from_millis(200)); // Note: This test verifies the click method compiles and runs without error. // In a real scenario with a menu, it would navigate to the target item. // For this basic test, we just verify the method works without panicking. // The NavigationCalculator tests already verify the navigation logic. } #[test] fn test_session_start_recording() { let session = Session::create( "echo".to_string(), vec!["test".to_string()], Dimensions::new(24, 80), ) .unwrap(); // Initially not recording assert!(!session.is_recording()); // Start recording let result = session.start_recording(); assert!(result.is_ok()); assert!(session.is_recording()); // Can't start recording again let result = session.start_recording(); assert!(result.is_err()); } #[test] fn test_session_stop_recording() { let session = Session::create( "echo".to_string(), vec!["test".to_string()], Dimensions::new(24, 80), ) .unwrap(); // Stop without start returns None let result = session.stop_recording(); assert!(result.is_none()); // Start then stop session.start_recording().unwrap(); assert!(session.is_recording()); let result = session.stop_recording(); assert!(result.is_some()); assert!(!session.is_recording()); } #[test] fn test_session_recording_io() { let session = Session::create( if cfg!(windows) { "cmd.exe" } else { "sh" }.to_string(), vec![], Dimensions::new(24, 80), ) .unwrap(); // Start recording session.start_recording().unwrap(); // Write some input session.write(b"echo hello\n").unwrap(); // Allow some time for output std::thread::sleep(Duration::from_millis(100)); // Process output session.process_output().unwrap(); // Stop recording let recorder = session.stop_recording().unwrap(); // Should have at least one event (the input) assert!(recorder.event_count() > 0); // Convert to string format let recording = recorder.to_string().unwrap(); assert!(recording.contains("\"version\":2")); assert!(recording.contains("echo hello")); } #[test] fn test_session_save_recording_without_start() { let session = Session::create( "echo".to_string(), vec!["test".to_string()], Dimensions::new(24, 80), ) .unwrap(); // Trying to save without starting should fail let result = session.save_recording("/tmp/test.cast"); assert!(result.is_err()); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aybelatchane/mcp-server-terminal'

If you have feedback or need assistance with the MCP directory API, please join our Discord server