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 portable_pty::{native_pty_system, CommandBuilder, PtySize};
24use std::collections::HashMap;
25use std::io::{Read, Write};
26use std::sync::atomic::AtomicBool;
27use std::sync::mpsc;
28use std::sync::{Arc, Mutex};
29use std::thread;
30
31pub use fresh_core::TerminalId;
32
33/// Messages sent to terminal I/O thread
34enum TerminalCommand {
35 /// Write data to PTY
36 Write(Vec<u8>),
37 /// Resize the PTY
38 Resize { cols: u16, rows: u16 },
39 /// Shutdown the terminal
40 Shutdown,
41}
42
43/// Handle to a running terminal session
44pub struct TerminalHandle {
45 /// Terminal state (grid, cursor, etc.)
46 pub state: Arc<Mutex<TerminalState>>,
47 /// Command sender to I/O thread
48 command_tx: mpsc::Sender<TerminalCommand>,
49 /// Whether the terminal is still alive
50 alive: Arc<std::sync::atomic::AtomicBool>,
51 /// Current dimensions
52 cols: u16,
53 rows: u16,
54 /// Working directory used for the terminal
55 cwd: Option<std::path::PathBuf>,
56 /// Shell executable used to spawn the terminal
57 shell: String,
58}
59
60impl TerminalHandle {
61 /// Write data to the terminal (sends to PTY)
62 pub fn write(&self, data: &[u8]) {
63 // Receiver may be dropped if terminal exited; nothing to do in that case.
64 #[allow(clippy::let_underscore_must_use)]
65 let _ = self.command_tx.send(TerminalCommand::Write(data.to_vec()));
66 }
67
68 /// Resize the terminal
69 pub fn resize(&mut self, cols: u16, rows: u16) {
70 if cols != self.cols || rows != self.rows {
71 self.cols = cols;
72 self.rows = rows;
73 // Receiver may be dropped if terminal exited; nothing to do in that case.
74 #[allow(clippy::let_underscore_must_use)]
75 let _ = self.command_tx.send(TerminalCommand::Resize { cols, rows });
76 // Also resize the terminal state
77 if let Ok(mut state) = self.state.lock() {
78 state.resize(cols, rows);
79 }
80 }
81 }
82
83 /// Check if the terminal is still running
84 pub fn is_alive(&self) -> bool {
85 self.alive.load(std::sync::atomic::Ordering::Relaxed)
86 }
87
88 /// Shutdown the terminal
89 pub fn shutdown(&self) {
90 // Receiver may be dropped if terminal already exited; nothing to do in that case.
91 #[allow(clippy::let_underscore_must_use)]
92 let _ = self.command_tx.send(TerminalCommand::Shutdown);
93 }
94
95 /// Get current dimensions
96 pub fn size(&self) -> (u16, u16) {
97 (self.cols, self.rows)
98 }
99
100 /// Get the working directory configured for the terminal
101 pub fn cwd(&self) -> Option<std::path::PathBuf> {
102 self.cwd.clone()
103 }
104
105 /// Get the shell executable path used for this terminal
106 pub fn shell(&self) -> &str {
107 &self.shell
108 }
109}
110
111/// Manager for multiple terminal sessions
112pub struct TerminalManager {
113 /// Map from terminal ID to handle
114 terminals: HashMap<TerminalId, TerminalHandle>,
115 /// Next terminal ID
116 next_id: usize,
117 /// Async bridge for sending notifications to main loop
118 async_bridge: Option<AsyncBridge>,
119}
120
121impl TerminalManager {
122 /// Create a new terminal manager
123 pub fn new() -> Self {
124 Self {
125 terminals: HashMap::new(),
126 next_id: 0,
127 async_bridge: None,
128 }
129 }
130
131 /// Set the async bridge for communication with main loop
132 pub fn set_async_bridge(&mut self, bridge: AsyncBridge) {
133 self.async_bridge = Some(bridge);
134 }
135
136 /// Peek at the next terminal ID that would be assigned.
137 pub fn next_terminal_id(&self) -> TerminalId {
138 TerminalId(self.next_id)
139 }
140
141 /// Spawn a new terminal session
142 ///
143 /// # Arguments
144 /// * `cols` - Initial terminal width in columns
145 /// * `rows` - Initial terminal height in rows
146 /// * `cwd` - Optional working directory (defaults to current directory)
147 /// * `log_path` - Optional path for raw PTY log (for session restore)
148 /// * `backing_path` - Optional path for rendered scrollback (incremental streaming)
149 ///
150 /// # Returns
151 /// The terminal ID if successful
152 pub fn spawn(
153 &mut self,
154 cols: u16,
155 rows: u16,
156 cwd: Option<std::path::PathBuf>,
157 log_path: Option<std::path::PathBuf>,
158 backing_path: Option<std::path::PathBuf>,
159 ) -> Result<TerminalId, String> {
160 let id = TerminalId(self.next_id);
161 self.next_id += 1;
162
163 // Try to spawn a real PTY-backed terminal first.
164 let handle_result: Result<TerminalHandle, String> = (|| {
165 // Create PTY
166 let pty_system = native_pty_system();
167 let pty_pair = pty_system
168 .openpty(PtySize {
169 rows,
170 cols,
171 pixel_width: 0,
172 pixel_height: 0,
173 })
174 .map_err(|e| {
175 #[cfg(windows)]
176 {
177 format!(
178 "Failed to open PTY: {}. Note: Terminal requires Windows 10 version 1809 or later with ConPTY support.",
179 e
180 )
181 }
182 #[cfg(not(windows))]
183 {
184 format!("Failed to open PTY: {}", e)
185 }
186 })?;
187
188 // Detect shell
189 let shell = detect_shell();
190 tracing::info!("Spawning terminal with shell: {}", shell);
191
192 // Build command
193 let mut cmd = CommandBuilder::new(&shell);
194 if let Some(ref dir) = cwd {
195 cmd.cwd(dir);
196 }
197
198 // Set TERM so programs like less know the terminal capabilities.
199 // The built-in emulator is alacritty-based so xterm-256color is appropriate.
200 cmd.env("TERM", "xterm-256color");
201
202 // On Windows, set additional environment variables that help with ConPTY
203 #[cfg(windows)]
204 {
205 // Ensure PROMPT is set for cmd.exe
206 if shell.to_lowercase().contains("cmd") {
207 cmd.env("PROMPT", "$P$G");
208 }
209 }
210
211 // Spawn the shell process
212 let mut child = pty_pair
213 .slave
214 .spawn_command(cmd)
215 .map_err(|e| format!("Failed to spawn shell '{}': {}", shell, e))?;
216
217 tracing::debug!("Shell process spawned successfully");
218
219 // Create terminal state
220 let state = Arc::new(Mutex::new(TerminalState::new(cols, rows)));
221
222 // Initialize backing_file_history_end if backing file already exists (session restore)
223 // This ensures enter_terminal_mode doesn't truncate existing history to 0
224 if let Some(ref p) = backing_path {
225 if let Ok(metadata) = std::fs::metadata(p) {
226 if metadata.len() > 0 {
227 if let Ok(mut s) = state.lock() {
228 s.set_backing_file_history_end(metadata.len());
229 }
230 }
231 }
232 }
233
234 // Create communication channel
235 let (command_tx, command_rx) = mpsc::channel::<TerminalCommand>();
236
237 // Alive flag
238 let alive = Arc::new(AtomicBool::new(true));
239 let alive_clone = alive.clone();
240
241 // Get master for I/O
242 let mut master = pty_pair
243 .master
244 .take_writer()
245 .map_err(|e| format!("Failed to get PTY writer: {}", e))?;
246
247 let mut reader = pty_pair
248 .master
249 .try_clone_reader()
250 .map_err(|e| format!("Failed to get PTY reader: {}", e))?;
251
252 // Clone state for reader thread
253 let state_clone = state.clone();
254 let async_bridge = self.async_bridge.clone();
255
256 // Optional raw log writer for full-session capture (for live terminal resume)
257 let mut log_writer = log_path
258 .as_ref()
259 .and_then(|p| {
260 std::fs::OpenOptions::new()
261 .create(true)
262 .append(true)
263 .open(p)
264 .ok()
265 })
266 .map(std::io::BufWriter::new);
267
268 // Backing file writer for incremental scrollback streaming
269 // During session restore, the backing file may already contain scrollback content.
270 // We open for append to continue streaming new scrollback after the existing content.
271 // For new terminals, append mode also works (creates file if needed).
272 let mut backing_writer = backing_path
273 .as_ref()
274 .and_then(|p| {
275 // Check if backing file exists and has content (session restore case)
276 let existing_has_content =
277 p.exists() && std::fs::metadata(p).map(|m| m.len() > 0).unwrap_or(false);
278
279 if existing_has_content {
280 // Session restore: open for append to continue streaming new scrollback
281 // The existing content is preserved and loaded into buffer separately.
282 // Note: enter_terminal_mode will truncate when user re-enters terminal.
283 std::fs::OpenOptions::new()
284 .create(true)
285 .append(true)
286 .open(p)
287 .ok()
288 } else {
289 // New terminal: start fresh with truncate
290 std::fs::OpenOptions::new()
291 .create(true)
292 .write(true)
293 .truncate(true)
294 .open(p)
295 .ok()
296 }
297 })
298 .map(std::io::BufWriter::new);
299
300 // Spawn reader thread
301 let terminal_id = id;
302 let pty_response_tx = command_tx.clone();
303 thread::spawn(move || {
304 tracing::debug!("Terminal {:?} reader thread started", terminal_id);
305 let mut buf = [0u8; 4096];
306 let mut total_bytes = 0usize;
307 loop {
308 match reader.read(&mut buf) {
309 Ok(0) => {
310 // EOF - process exited
311 tracing::info!(
312 "Terminal {:?} EOF after {} total bytes",
313 terminal_id,
314 total_bytes
315 );
316 break;
317 }
318 Ok(n) => {
319 total_bytes += n;
320 tracing::debug!(
321 "Terminal {:?} received {} bytes (total: {})",
322 terminal_id,
323 n,
324 total_bytes
325 );
326 // Process output through terminal emulator and stream scrollback
327 if let Ok(mut state) = state_clone.lock() {
328 state.process_output(&buf[..n]);
329
330 // Send any PTY write responses (e.g., DSR cursor position)
331 // This is critical for Windows ConPTY where PowerShell waits
332 // for cursor position response before showing the prompt
333 for response in state.drain_pty_write_queue() {
334 tracing::debug!(
335 "Terminal {:?} sending PTY response: {:?}",
336 terminal_id,
337 response
338 );
339 // Receiver may be dropped if writer thread exited.
340 #[allow(clippy::let_underscore_must_use)]
341 let _ = pty_response_tx
342 .send(TerminalCommand::Write(response.into_bytes()));
343 }
344
345 // Incrementally stream new scrollback lines to backing file
346 if let Some(ref mut writer) = backing_writer {
347 match state.flush_new_scrollback(writer) {
348 Ok(lines_written) => {
349 if lines_written > 0 {
350 // Update the history end offset
351 if let Ok(pos) = writer.get_ref().metadata() {
352 state.set_backing_file_history_end(pos.len());
353 }
354 // Best-effort flush; backing file errors handled below.
355 #[allow(clippy::let_underscore_must_use)]
356 let _ = writer.flush();
357 }
358 }
359 Err(e) => {
360 tracing::warn!(
361 "Terminal backing file write error: {}",
362 e
363 );
364 backing_writer = None;
365 }
366 }
367 }
368 }
369
370 // Append raw bytes to log if available (for session restore replay)
371 if let Some(w) = log_writer.as_mut() {
372 if let Err(e) = w.write_all(&buf[..n]) {
373 tracing::warn!("Terminal log write error: {}", e);
374 log_writer = None; // stop logging on error
375 } else if let Err(e) = w.flush() {
376 tracing::warn!("Terminal log flush error: {}", e);
377 log_writer = None;
378 }
379 }
380
381 // Notify main loop to redraw (receiver may be dropped during shutdown).
382 if let Some(ref bridge) = async_bridge {
383 #[allow(clippy::let_underscore_must_use)]
384 let _ = bridge.sender().send(
385 crate::services::async_bridge::AsyncMessage::TerminalOutput {
386 terminal_id,
387 },
388 );
389 }
390 }
391 Err(e) => {
392 tracing::error!("Terminal read error: {}", e);
393 break;
394 }
395 }
396 }
397 alive_clone.store(false, std::sync::atomic::Ordering::Relaxed);
398 // Best-effort flush of log/backing files during teardown.
399 if let Some(mut w) = log_writer {
400 #[allow(clippy::let_underscore_must_use)]
401 let _ = w.flush();
402 }
403 if let Some(mut w) = backing_writer {
404 #[allow(clippy::let_underscore_must_use)]
405 let _ = w.flush();
406 }
407 // Notify that terminal exited (receiver may be dropped during shutdown).
408 if let Some(ref bridge) = async_bridge {
409 #[allow(clippy::let_underscore_must_use)]
410 let _ = bridge.sender().send(
411 crate::services::async_bridge::AsyncMessage::TerminalExited { terminal_id },
412 );
413 }
414 });
415
416 // Spawn writer thread
417 let pty_size_ref = pty_pair.master;
418 thread::spawn(move || {
419 loop {
420 match command_rx.recv() {
421 Ok(TerminalCommand::Write(data)) => {
422 if let Err(e) = master.write_all(&data) {
423 tracing::error!("Terminal write error: {}", e);
424 break;
425 }
426 // Best-effort flush — PTY write errors are handled above.
427 #[allow(clippy::let_underscore_must_use)]
428 let _ = master.flush();
429 }
430 Ok(TerminalCommand::Resize { cols, rows }) => {
431 if let Err(e) = pty_size_ref.resize(PtySize {
432 rows,
433 cols,
434 pixel_width: 0,
435 pixel_height: 0,
436 }) {
437 tracing::warn!("Failed to resize PTY: {}", e);
438 }
439 }
440 Ok(TerminalCommand::Shutdown) | Err(_) => {
441 break;
442 }
443 }
444 }
445 // Best-effort child process cleanup during teardown.
446 #[allow(clippy::let_underscore_must_use)]
447 let _ = child.kill();
448 #[allow(clippy::let_underscore_must_use)]
449 let _ = child.wait();
450 });
451
452 // Create handle
453 Ok(TerminalHandle {
454 state,
455 command_tx,
456 alive,
457 cols,
458 rows,
459 cwd: cwd.clone(),
460 shell,
461 })
462 })();
463
464 let handle = handle_result?;
465
466 self.terminals.insert(id, handle);
467 tracing::info!("Created terminal {:?} ({}x{})", id, cols, rows);
468
469 Ok(id)
470 }
471
472 /// Get a terminal handle by ID
473 pub fn get(&self, id: TerminalId) -> Option<&TerminalHandle> {
474 self.terminals.get(&id)
475 }
476
477 /// Get a mutable terminal handle by ID
478 pub fn get_mut(&mut self, id: TerminalId) -> Option<&mut TerminalHandle> {
479 self.terminals.get_mut(&id)
480 }
481
482 /// Close a terminal
483 pub fn close(&mut self, id: TerminalId) -> bool {
484 if let Some(handle) = self.terminals.remove(&id) {
485 handle.shutdown();
486 true
487 } else {
488 false
489 }
490 }
491
492 /// Get all terminal IDs
493 pub fn terminal_ids(&self) -> Vec<TerminalId> {
494 self.terminals.keys().copied().collect()
495 }
496
497 /// Get count of open terminals
498 pub fn count(&self) -> usize {
499 self.terminals.len()
500 }
501
502 /// Shutdown all terminals
503 pub fn shutdown_all(&mut self) {
504 for (_, handle) in self.terminals.drain() {
505 handle.shutdown();
506 }
507 }
508
509 /// Clean up dead terminals
510 pub fn cleanup_dead(&mut self) -> Vec<TerminalId> {
511 let dead: Vec<TerminalId> = self
512 .terminals
513 .iter()
514 .filter(|(_, h)| !h.is_alive())
515 .map(|(id, _)| *id)
516 .collect();
517
518 for id in &dead {
519 self.terminals.remove(id);
520 }
521
522 dead
523 }
524}
525
526impl Default for TerminalManager {
527 fn default() -> Self {
528 Self::new()
529 }
530}
531
532impl Drop for TerminalManager {
533 fn drop(&mut self) {
534 self.shutdown_all();
535 }
536}
537
538/// Detect the user's shell
539pub fn detect_shell() -> String {
540 // Try $SHELL environment variable first
541 if let Ok(shell) = std::env::var("SHELL") {
542 if !shell.is_empty() {
543 return shell;
544 }
545 }
546
547 // Fall back to platform defaults
548 #[cfg(unix)]
549 {
550 "/bin/sh".to_string()
551 }
552 #[cfg(windows)]
553 {
554 // On Windows, prefer PowerShell for better ConPTY and ANSI escape support
555 // Check for PowerShell Core (pwsh) first, then Windows PowerShell
556 let powershell_paths = [
557 "pwsh.exe",
558 "powershell.exe",
559 r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
560 ];
561
562 for ps in &powershell_paths {
563 if std::path::Path::new(ps).exists() || which_exists(ps) {
564 return ps.to_string();
565 }
566 }
567
568 // Fall back to COMSPEC (cmd.exe)
569 std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
570 }
571}
572
573/// Check if command exists in PATH (Windows)
574#[cfg(windows)]
575fn which_exists(cmd: &str) -> bool {
576 if let Ok(path_var) = std::env::var("PATH") {
577 for path in path_var.split(';') {
578 let full_path = std::path::Path::new(path).join(cmd);
579 if full_path.exists() {
580 return true;
581 }
582 }
583 }
584 false
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn test_terminal_id_display() {
593 let id = TerminalId(42);
594 assert_eq!(format!("{}", id), "Terminal-42");
595 }
596
597 #[test]
598 fn test_detect_shell() {
599 let shell = detect_shell();
600 assert!(!shell.is_empty());
601 }
602}