fresh/services/terminal/manager.rs
1//! Terminal Manager - manages multiple terminal sessions
2//!
3//! This module provides a manager for terminal sessions that:
4//! - Spawns PTY processes with proper shell detection
5//! - Manages multiple concurrent terminals
6//! - Routes input/output between the editor and terminal processes
7//! - Handles terminal resize events
8//!
9//! # Role in Incremental Streaming Architecture
10//!
11//! The manager owns the PTY read loop which is the entry point for incremental
12//! scrollback streaming. See `super` module docs for the full architecture overview.
13//!
14//! ## PTY Read Loop
15//!
16//! The read loop in `spawn()` performs incremental streaming: for each PTY read,
17//! it calls `process_output()` to update the terminal grid, then `flush_new_scrollback()`
18//! to append any new scrollback lines to the backing file. This ensures scrollback is
19//! written incrementally as lines scroll off screen, avoiding O(n) work on mode switches.
20
21use super::term::TerminalState;
22use crate::services::async_bridge::AsyncBridge;
23use crate::services::authority::TerminalWrapper;
24use portable_pty::{native_pty_system, CommandBuilder, PtySize};
25use std::borrow::Cow;
26use std::collections::HashMap;
27use std::io::{Read, Write};
28use std::sync::atomic::AtomicBool;
29use std::sync::mpsc;
30use std::sync::{Arc, Mutex};
31use std::thread;
32
33pub use fresh_core::TerminalId;
34
35/// Messages sent to terminal I/O thread
36enum TerminalCommand {
37 /// Write data to PTY
38 Write(Vec<u8>),
39 /// Resize the PTY
40 Resize { cols: u16, rows: u16 },
41 /// Shutdown the terminal
42 Shutdown,
43}
44
45/// Handle to a running terminal session
46pub struct TerminalHandle {
47 /// Terminal state (grid, cursor, etc.)
48 pub state: Arc<Mutex<TerminalState>>,
49 /// Command sender to I/O thread
50 command_tx: mpsc::Sender<TerminalCommand>,
51 /// Whether the terminal is still alive
52 alive: Arc<std::sync::atomic::AtomicBool>,
53 /// Current dimensions
54 cols: u16,
55 rows: u16,
56 /// Working directory used for the terminal
57 cwd: Option<std::path::PathBuf>,
58 /// Shell executable used to spawn the terminal
59 shell: String,
60}
61
62impl TerminalHandle {
63 /// Write data to the terminal (sends to PTY)
64 pub fn write(&self, data: &[u8]) {
65 // Receiver may be dropped if terminal exited; nothing to do in that case.
66 #[allow(clippy::let_underscore_must_use)]
67 let _ = self.command_tx.send(TerminalCommand::Write(data.to_vec()));
68 }
69
70 /// Resize the terminal
71 pub fn resize(&mut self, cols: u16, rows: u16) {
72 if cols != self.cols || rows != self.rows {
73 self.cols = cols;
74 self.rows = rows;
75 // Receiver may be dropped if terminal exited; nothing to do in that case.
76 #[allow(clippy::let_underscore_must_use)]
77 let _ = self.command_tx.send(TerminalCommand::Resize { cols, rows });
78 // Also resize the terminal state
79 if let Ok(mut state) = self.state.lock() {
80 state.resize(cols, rows);
81 }
82 }
83 }
84
85 /// Check if the terminal is still running
86 pub fn is_alive(&self) -> bool {
87 self.alive.load(std::sync::atomic::Ordering::Relaxed)
88 }
89
90 /// Shutdown the terminal
91 pub fn shutdown(&self) {
92 // Receiver may be dropped if terminal already exited; nothing to do in that case.
93 #[allow(clippy::let_underscore_must_use)]
94 let _ = self.command_tx.send(TerminalCommand::Shutdown);
95 }
96
97 /// Get current dimensions
98 pub fn size(&self) -> (u16, u16) {
99 (self.cols, self.rows)
100 }
101
102 /// Get the working directory configured for the terminal
103 pub fn cwd(&self) -> Option<std::path::PathBuf> {
104 self.cwd.clone()
105 }
106
107 /// Get the shell executable path used for this terminal
108 pub fn shell(&self) -> &str {
109 &self.shell
110 }
111}
112
113/// Manager for multiple terminal sessions
114pub struct TerminalManager {
115 /// Map from terminal ID to handle
116 terminals: HashMap<TerminalId, TerminalHandle>,
117 /// Next terminal ID
118 next_id: usize,
119 /// Async bridge for sending notifications to main loop
120 async_bridge: Option<AsyncBridge>,
121}
122
123impl TerminalManager {
124 /// Create a new terminal manager
125 pub fn new() -> Self {
126 Self {
127 terminals: HashMap::new(),
128 next_id: 0,
129 async_bridge: None,
130 }
131 }
132
133 /// Set the async bridge for communication with main loop
134 pub fn set_async_bridge(&mut self, bridge: AsyncBridge) {
135 self.async_bridge = Some(bridge);
136 }
137
138 /// Peek at the next terminal ID that would be assigned.
139 pub fn next_terminal_id(&self) -> TerminalId {
140 TerminalId(self.next_id)
141 }
142
143 /// Spawn a new terminal session
144 ///
145 /// # Arguments
146 /// * `cols` - Initial terminal width in columns
147 /// * `rows` - Initial terminal height in rows
148 /// * `cwd` - Optional working directory (defaults to current directory)
149 /// * `log_path` - Optional path for raw PTY log (for session restore)
150 /// * `backing_path` - Optional path for rendered scrollback (incremental streaming)
151 ///
152 /// # Returns
153 /// The terminal ID if successful
154 pub fn spawn(
155 &mut self,
156 cols: u16,
157 rows: u16,
158 cwd: Option<std::path::PathBuf>,
159 log_path: Option<std::path::PathBuf>,
160 backing_path: Option<std::path::PathBuf>,
161 terminal_wrapper: crate::services::authority::TerminalWrapper,
162 ) -> Result<TerminalId, String> {
163 let id = TerminalId(self.next_id);
164 self.next_id += 1;
165
166 // Try to spawn a real PTY-backed terminal first.
167 let handle_result: Result<TerminalHandle, String> = (|| {
168 // Create PTY
169 let pty_system = native_pty_system();
170 let pty_pair = pty_system
171 .openpty(PtySize {
172 rows,
173 cols,
174 pixel_width: 0,
175 pixel_height: 0,
176 })
177 .map_err(|e| {
178 #[cfg(windows)]
179 {
180 format!(
181 "Failed to open PTY: {}. Note: Terminal requires Windows 10 version 1809 or later with ConPTY support.",
182 e
183 )
184 }
185 #[cfg(not(windows))]
186 {
187 format!("Failed to open PTY: {}", e)
188 }
189 })?;
190
191 // The active authority's terminal wrapper drives the shell
192 // command unconditionally — local wraps `detect_shell()` with
193 // no args; container/remote authorities re-parent into
194 // `docker exec -w …`, `ssh …`, etc. `manages_cwd` says
195 // whether the wrapper's args already establish cwd (in which
196 // case `CommandBuilder::cwd()` is skipped).
197 let TerminalWrapper {
198 command: shell,
199 args: cmd_args,
200 manages_cwd: skip_cwd,
201 } = terminal_wrapper;
202 tracing::info!("Spawning terminal with shell: {}", shell);
203
204 let mut cmd = CommandBuilder::new(&shell);
205 for arg in &cmd_args {
206 cmd.arg(arg);
207 }
208 if !skip_cwd {
209 if let Some(ref dir) = cwd {
210 // Hand the shell a non-verbatim path. Fresh canonicalizes
211 // working_dir at startup, which on Windows yields
212 // `\\?\C:\…`. PowerShell can't infer a drive from a
213 // verbatim path and falls back to the fully-qualified
214 // provider form, producing prompts like
215 // `PS Microsoft.PowerShell.Core\FileSystem::\\?\C:\…>`.
216 cmd.cwd(strip_verbatim_prefix(dir).as_ref());
217 }
218 }
219
220 // Set TERM so programs like less know the terminal capabilities.
221 // The built-in emulator is alacritty-based so xterm-256color is appropriate.
222 cmd.env("TERM", "xterm-256color");
223
224 // On Windows, set additional environment variables that help with ConPTY
225 #[cfg(windows)]
226 {
227 // Ensure PROMPT is set for cmd.exe
228 if shell.to_lowercase().contains("cmd") {
229 cmd.env("PROMPT", "$P$G");
230 }
231 }
232
233 // Spawn the shell process
234 let mut child = pty_pair
235 .slave
236 .spawn_command(cmd)
237 .map_err(|e| format!("Failed to spawn shell '{}': {}", shell, e))?;
238
239 tracing::debug!("Shell process spawned successfully");
240
241 // Create terminal state
242 let state = Arc::new(Mutex::new(TerminalState::new(cols, rows)));
243
244 // Initialize backing_file_history_end if backing file already exists (session restore)
245 // This ensures enter_terminal_mode doesn't truncate existing history to 0
246 if let Some(ref p) = backing_path {
247 if let Ok(metadata) = std::fs::metadata(p) {
248 if metadata.len() > 0 {
249 if let Ok(mut s) = state.lock() {
250 s.set_backing_file_history_end(metadata.len());
251 }
252 }
253 }
254 }
255
256 // Create communication channel
257 let (command_tx, command_rx) = mpsc::channel::<TerminalCommand>();
258
259 // Alive flag
260 let alive = Arc::new(AtomicBool::new(true));
261 let alive_clone = alive.clone();
262
263 // Get master for I/O
264 let mut master = pty_pair
265 .master
266 .take_writer()
267 .map_err(|e| format!("Failed to get PTY writer: {}", e))?;
268
269 let mut reader = pty_pair
270 .master
271 .try_clone_reader()
272 .map_err(|e| format!("Failed to get PTY reader: {}", e))?;
273
274 // Clone state for reader thread
275 let state_clone = state.clone();
276 let async_bridge = self.async_bridge.clone();
277
278 // Optional raw log writer for full-session capture (for live terminal resume)
279 let mut log_writer = log_path
280 .as_ref()
281 .and_then(|p| {
282 std::fs::OpenOptions::new()
283 .create(true)
284 .append(true)
285 .open(p)
286 .ok()
287 })
288 .map(std::io::BufWriter::new);
289
290 // Backing file writer for incremental scrollback streaming
291 // During session restore, the backing file may already contain scrollback content.
292 // We open for append to continue streaming new scrollback after the existing content.
293 // For new terminals, append mode also works (creates file if needed).
294 let mut backing_writer = backing_path
295 .as_ref()
296 .and_then(|p| {
297 // Check if backing file exists and has content (session restore case)
298 let existing_has_content =
299 p.exists() && std::fs::metadata(p).map(|m| m.len() > 0).unwrap_or(false);
300
301 if existing_has_content {
302 // Session restore: open for append to continue streaming new scrollback
303 // The existing content is preserved and loaded into buffer separately.
304 // Note: enter_terminal_mode will truncate when user re-enters terminal.
305 std::fs::OpenOptions::new()
306 .create(true)
307 .append(true)
308 .open(p)
309 .ok()
310 } else {
311 // New terminal: start fresh with truncate
312 std::fs::OpenOptions::new()
313 .create(true)
314 .write(true)
315 .truncate(true)
316 .open(p)
317 .ok()
318 }
319 })
320 .map(std::io::BufWriter::new);
321
322 // Spawn reader thread
323 let terminal_id = id;
324 let pty_response_tx = command_tx.clone();
325 thread::spawn(move || {
326 tracing::debug!("Terminal {:?} reader thread started", terminal_id);
327 let mut buf = [0u8; 4096];
328 let mut total_bytes = 0usize;
329 loop {
330 match reader.read(&mut buf) {
331 Ok(0) => {
332 // EOF - process exited
333 tracing::info!(
334 "Terminal {:?} EOF after {} total bytes",
335 terminal_id,
336 total_bytes
337 );
338 break;
339 }
340 Ok(n) => {
341 total_bytes += n;
342 tracing::debug!(
343 "Terminal {:?} received {} bytes (total: {})",
344 terminal_id,
345 n,
346 total_bytes
347 );
348 // Process output through terminal emulator and stream scrollback
349 if let Ok(mut state) = state_clone.lock() {
350 state.process_output(&buf[..n]);
351
352 // Send any PTY write responses (e.g., DSR cursor position)
353 // This is critical for Windows ConPTY where PowerShell waits
354 // for cursor position response before showing the prompt
355 for response in state.drain_pty_write_queue() {
356 tracing::debug!(
357 "Terminal {:?} sending PTY response: {:?}",
358 terminal_id,
359 response
360 );
361 // Receiver may be dropped if writer thread exited.
362 #[allow(clippy::let_underscore_must_use)]
363 let _ = pty_response_tx
364 .send(TerminalCommand::Write(response.into_bytes()));
365 }
366
367 // Incrementally stream new scrollback lines to backing file
368 if let Some(ref mut writer) = backing_writer {
369 match state.flush_new_scrollback(writer) {
370 Ok(lines_written) => {
371 if lines_written > 0 {
372 // Update the history end offset
373 if let Ok(pos) = writer.get_ref().metadata() {
374 state.set_backing_file_history_end(pos.len());
375 }
376 // Best-effort flush; backing file errors handled below.
377 #[allow(clippy::let_underscore_must_use)]
378 let _ = writer.flush();
379 }
380 }
381 Err(e) => {
382 tracing::warn!(
383 "Terminal backing file write error: {}",
384 e
385 );
386 backing_writer = None;
387 }
388 }
389 }
390 }
391
392 // Append raw bytes to log if available (for session restore replay)
393 if let Some(w) = log_writer.as_mut() {
394 if let Err(e) = w.write_all(&buf[..n]) {
395 tracing::warn!("Terminal log write error: {}", e);
396 log_writer = None; // stop logging on error
397 } else if let Err(e) = w.flush() {
398 tracing::warn!("Terminal log flush error: {}", e);
399 log_writer = None;
400 }
401 }
402
403 // Notify main loop to redraw (receiver may be dropped during shutdown).
404 if let Some(ref bridge) = async_bridge {
405 #[allow(clippy::let_underscore_must_use)]
406 let _ = bridge.sender().send(
407 crate::services::async_bridge::AsyncMessage::TerminalOutput {
408 terminal_id,
409 },
410 );
411 }
412 }
413 Err(e) => {
414 tracing::error!("Terminal read error: {}", e);
415 break;
416 }
417 }
418 }
419 alive_clone.store(false, std::sync::atomic::Ordering::Relaxed);
420 // Best-effort flush of log/backing files during teardown.
421 if let Some(mut w) = log_writer {
422 #[allow(clippy::let_underscore_must_use)]
423 let _ = w.flush();
424 }
425 if let Some(mut w) = backing_writer {
426 #[allow(clippy::let_underscore_must_use)]
427 let _ = w.flush();
428 }
429 // Notify that terminal exited (receiver may be dropped during shutdown).
430 if let Some(ref bridge) = async_bridge {
431 #[allow(clippy::let_underscore_must_use)]
432 let _ = bridge.sender().send(
433 crate::services::async_bridge::AsyncMessage::TerminalExited { terminal_id },
434 );
435 }
436 });
437
438 // Spawn writer thread
439 let pty_size_ref = pty_pair.master;
440 thread::spawn(move || {
441 loop {
442 match command_rx.recv() {
443 Ok(TerminalCommand::Write(data)) => {
444 if let Err(e) = master.write_all(&data) {
445 tracing::error!("Terminal write error: {}", e);
446 break;
447 }
448 // Best-effort flush — PTY write errors are handled above.
449 #[allow(clippy::let_underscore_must_use)]
450 let _ = master.flush();
451 }
452 Ok(TerminalCommand::Resize { cols, rows }) => {
453 if let Err(e) = pty_size_ref.resize(PtySize {
454 rows,
455 cols,
456 pixel_width: 0,
457 pixel_height: 0,
458 }) {
459 tracing::warn!("Failed to resize PTY: {}", e);
460 }
461 }
462 Ok(TerminalCommand::Shutdown) | Err(_) => {
463 break;
464 }
465 }
466 }
467 // Best-effort child process cleanup during teardown.
468 #[allow(clippy::let_underscore_must_use)]
469 let _ = child.kill();
470 #[allow(clippy::let_underscore_must_use)]
471 let _ = child.wait();
472 });
473
474 // Create handle
475 Ok(TerminalHandle {
476 state,
477 command_tx,
478 alive,
479 cols,
480 rows,
481 cwd: cwd.clone(),
482 shell,
483 })
484 })();
485
486 let handle = handle_result?;
487
488 self.terminals.insert(id, handle);
489 tracing::info!("Created terminal {:?} ({}x{})", id, cols, rows);
490
491 Ok(id)
492 }
493
494 /// Get a terminal handle by ID
495 pub fn get(&self, id: TerminalId) -> Option<&TerminalHandle> {
496 self.terminals.get(&id)
497 }
498
499 /// Get a mutable terminal handle by ID
500 pub fn get_mut(&mut self, id: TerminalId) -> Option<&mut TerminalHandle> {
501 self.terminals.get_mut(&id)
502 }
503
504 /// Close a terminal
505 pub fn close(&mut self, id: TerminalId) -> bool {
506 if let Some(handle) = self.terminals.remove(&id) {
507 handle.shutdown();
508 true
509 } else {
510 false
511 }
512 }
513
514 /// Get all terminal IDs
515 pub fn terminal_ids(&self) -> Vec<TerminalId> {
516 self.terminals.keys().copied().collect()
517 }
518
519 /// Get count of open terminals
520 pub fn count(&self) -> usize {
521 self.terminals.len()
522 }
523
524 /// Shutdown all terminals
525 pub fn shutdown_all(&mut self) {
526 for (_, handle) in self.terminals.drain() {
527 handle.shutdown();
528 }
529 }
530
531 /// Clean up dead terminals
532 pub fn cleanup_dead(&mut self) -> Vec<TerminalId> {
533 let dead: Vec<TerminalId> = self
534 .terminals
535 .iter()
536 .filter(|(_, h)| !h.is_alive())
537 .map(|(id, _)| *id)
538 .collect();
539
540 for id in &dead {
541 self.terminals.remove(id);
542 }
543
544 dead
545 }
546}
547
548impl Default for TerminalManager {
549 fn default() -> Self {
550 Self::new()
551 }
552}
553
554impl Drop for TerminalManager {
555 fn drop(&mut self) {
556 self.shutdown_all();
557 }
558}
559
560/// Convert a Windows verbatim path (`\\?\C:\…` or `\\?\UNC\server\share\…`)
561/// into its non-verbatim equivalent (`C:\…` or `\\server\share\…`).
562///
563/// Returns the input unchanged on non-Windows platforms or for paths that
564/// have no verbatim prefix.
565pub(crate) fn strip_verbatim_prefix(path: &std::path::Path) -> Cow<'_, std::path::Path> {
566 #[cfg(windows)]
567 {
568 use std::path::{Component, Prefix};
569
570 let mut components = path.components();
571 let prefix = match components.next() {
572 Some(Component::Prefix(p)) => p,
573 _ => return Cow::Borrowed(path),
574 };
575
576 let mut rebuilt = std::path::PathBuf::new();
577 match prefix.kind() {
578 Prefix::VerbatimDisk(drive) => {
579 rebuilt.push(format!("{}:\\", drive as char));
580 }
581 Prefix::VerbatimUNC(server, share) => {
582 rebuilt.push(format!(
583 r"\\{}\{}\",
584 server.to_string_lossy(),
585 share.to_string_lossy()
586 ));
587 }
588 _ => return Cow::Borrowed(path),
589 }
590 // Skip the original RootDir (which the rebuilt prefix already includes)
591 // and append the rest of the components.
592 for component in components {
593 if matches!(component, Component::RootDir) {
594 continue;
595 }
596 rebuilt.push(component.as_os_str());
597 }
598 Cow::Owned(rebuilt)
599 }
600 #[cfg(not(windows))]
601 {
602 Cow::Borrowed(path)
603 }
604}
605
606/// Detect the user's shell
607pub fn detect_shell() -> String {
608 // Try $SHELL environment variable first
609 if let Ok(shell) = std::env::var("SHELL") {
610 if !shell.is_empty() {
611 return shell;
612 }
613 }
614
615 // Fall back to platform defaults
616 #[cfg(unix)]
617 {
618 "/bin/sh".to_string()
619 }
620 #[cfg(windows)]
621 {
622 // On Windows, prefer PowerShell for better ConPTY and ANSI escape support
623 // Check for PowerShell Core (pwsh) first, then Windows PowerShell
624 let powershell_paths = [
625 "pwsh.exe",
626 "powershell.exe",
627 r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
628 ];
629
630 for ps in &powershell_paths {
631 if std::path::Path::new(ps).exists() || which_exists(ps) {
632 return ps.to_string();
633 }
634 }
635
636 // Fall back to COMSPEC (cmd.exe)
637 std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
638 }
639}
640
641/// Check if command exists in PATH (Windows)
642#[cfg(windows)]
643fn which_exists(cmd: &str) -> bool {
644 if let Ok(path_var) = std::env::var("PATH") {
645 for path in path_var.split(';') {
646 let full_path = std::path::Path::new(path).join(cmd);
647 if full_path.exists() {
648 return true;
649 }
650 }
651 }
652 false
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658
659 #[test]
660 fn test_terminal_id_display() {
661 let id = TerminalId(42);
662 assert_eq!(format!("{}", id), "Terminal-42");
663 }
664
665 #[test]
666 fn test_detect_shell() {
667 let shell = detect_shell();
668 assert!(!shell.is_empty());
669 }
670
671 #[cfg(not(windows))]
672 #[test]
673 fn strip_verbatim_prefix_is_noop_on_unix() {
674 use std::path::Path;
675 let p = Path::new("/home/user/project");
676 assert_eq!(strip_verbatim_prefix(p).as_ref(), p);
677 }
678
679 #[cfg(windows)]
680 #[test]
681 fn strip_verbatim_prefix_removes_verbatim_disk() {
682 use std::path::{Path, PathBuf};
683 let verbatim = PathBuf::from(r"\\?\C:\Users\HP\OneDrive\Desktop\PY'PGMS");
684 let stripped = strip_verbatim_prefix(&verbatim);
685 assert_eq!(
686 stripped.as_ref(),
687 Path::new(r"C:\Users\HP\OneDrive\Desktop\PY'PGMS"),
688 "verbatim disk prefix should be replaced with plain drive form"
689 );
690 }
691
692 #[cfg(windows)]
693 #[test]
694 fn strip_verbatim_prefix_removes_verbatim_unc() {
695 use std::path::{Path, PathBuf};
696 let verbatim = PathBuf::from(r"\\?\UNC\server\share\dir\file");
697 let stripped = strip_verbatim_prefix(&verbatim);
698 assert_eq!(
699 stripped.as_ref(),
700 Path::new(r"\\server\share\dir\file"),
701 "verbatim UNC prefix should be replaced with plain UNC form"
702 );
703 }
704
705 #[cfg(windows)]
706 #[test]
707 fn strip_verbatim_prefix_passes_plain_paths_through() {
708 use std::path::{Path, PathBuf};
709 let plain = PathBuf::from(r"C:\Users\HP\project");
710 let result = strip_verbatim_prefix(&plain);
711 assert_eq!(result.as_ref(), Path::new(r"C:\Users\HP\project"));
712 }
713}