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 // On Windows, set environment variables that help with ConPTY
199 #[cfg(windows)]
200 {
201 // Set TERM to help shells understand they're in a terminal
202 cmd.env("TERM", "xterm-256color");
203 // Ensure PROMPT is set for cmd.exe
204 if shell.to_lowercase().contains("cmd") {
205 cmd.env("PROMPT", "$P$G");
206 }
207 }
208
209 // Spawn the shell process
210 let mut child = pty_pair
211 .slave
212 .spawn_command(cmd)
213 .map_err(|e| format!("Failed to spawn shell '{}': {}", shell, e))?;
214
215 tracing::debug!("Shell process spawned successfully");
216
217 // Create terminal state
218 let state = Arc::new(Mutex::new(TerminalState::new(cols, rows)));
219
220 // Initialize backing_file_history_end if backing file already exists (session restore)
221 // This ensures enter_terminal_mode doesn't truncate existing history to 0
222 if let Some(ref p) = backing_path {
223 if let Ok(metadata) = std::fs::metadata(p) {
224 if metadata.len() > 0 {
225 if let Ok(mut s) = state.lock() {
226 s.set_backing_file_history_end(metadata.len());
227 }
228 }
229 }
230 }
231
232 // Create communication channel
233 let (command_tx, command_rx) = mpsc::channel::<TerminalCommand>();
234
235 // Alive flag
236 let alive = Arc::new(AtomicBool::new(true));
237 let alive_clone = alive.clone();
238
239 // Get master for I/O
240 let mut master = pty_pair
241 .master
242 .take_writer()
243 .map_err(|e| format!("Failed to get PTY writer: {}", e))?;
244
245 let mut reader = pty_pair
246 .master
247 .try_clone_reader()
248 .map_err(|e| format!("Failed to get PTY reader: {}", e))?;
249
250 // Clone state for reader thread
251 let state_clone = state.clone();
252 let async_bridge = self.async_bridge.clone();
253
254 // Optional raw log writer for full-session capture (for live terminal resume)
255 let mut log_writer = log_path
256 .as_ref()
257 .and_then(|p| {
258 std::fs::OpenOptions::new()
259 .create(true)
260 .append(true)
261 .open(p)
262 .ok()
263 })
264 .map(std::io::BufWriter::new);
265
266 // Backing file writer for incremental scrollback streaming
267 // During session restore, the backing file may already contain scrollback content.
268 // We open for append to continue streaming new scrollback after the existing content.
269 // For new terminals, append mode also works (creates file if needed).
270 let mut backing_writer = backing_path
271 .as_ref()
272 .and_then(|p| {
273 // Check if backing file exists and has content (session restore case)
274 let existing_has_content =
275 p.exists() && std::fs::metadata(p).map(|m| m.len() > 0).unwrap_or(false);
276
277 if existing_has_content {
278 // Session restore: open for append to continue streaming new scrollback
279 // The existing content is preserved and loaded into buffer separately.
280 // Note: enter_terminal_mode will truncate when user re-enters terminal.
281 std::fs::OpenOptions::new()
282 .create(true)
283 .append(true)
284 .open(p)
285 .ok()
286 } else {
287 // New terminal: start fresh with truncate
288 std::fs::OpenOptions::new()
289 .create(true)
290 .write(true)
291 .truncate(true)
292 .open(p)
293 .ok()
294 }
295 })
296 .map(std::io::BufWriter::new);
297
298 // Spawn reader thread
299 let terminal_id = id;
300 let pty_response_tx = command_tx.clone();
301 thread::spawn(move || {
302 tracing::debug!("Terminal {:?} reader thread started", terminal_id);
303 let mut buf = [0u8; 4096];
304 let mut total_bytes = 0usize;
305 loop {
306 match reader.read(&mut buf) {
307 Ok(0) => {
308 // EOF - process exited
309 tracing::info!(
310 "Terminal {:?} EOF after {} total bytes",
311 terminal_id,
312 total_bytes
313 );
314 break;
315 }
316 Ok(n) => {
317 total_bytes += n;
318 tracing::debug!(
319 "Terminal {:?} received {} bytes (total: {})",
320 terminal_id,
321 n,
322 total_bytes
323 );
324 // Process output through terminal emulator and stream scrollback
325 if let Ok(mut state) = state_clone.lock() {
326 state.process_output(&buf[..n]);
327
328 // Send any PTY write responses (e.g., DSR cursor position)
329 // This is critical for Windows ConPTY where PowerShell waits
330 // for cursor position response before showing the prompt
331 for response in state.drain_pty_write_queue() {
332 tracing::debug!(
333 "Terminal {:?} sending PTY response: {:?}",
334 terminal_id,
335 response
336 );
337 // Receiver may be dropped if writer thread exited.
338 #[allow(clippy::let_underscore_must_use)]
339 let _ = pty_response_tx
340 .send(TerminalCommand::Write(response.into_bytes()));
341 }
342
343 // Incrementally stream new scrollback lines to backing file
344 if let Some(ref mut writer) = backing_writer {
345 match state.flush_new_scrollback(writer) {
346 Ok(lines_written) => {
347 if lines_written > 0 {
348 // Update the history end offset
349 if let Ok(pos) = writer.get_ref().metadata() {
350 state.set_backing_file_history_end(pos.len());
351 }
352 // Best-effort flush; backing file errors handled below.
353 #[allow(clippy::let_underscore_must_use)]
354 let _ = writer.flush();
355 }
356 }
357 Err(e) => {
358 tracing::warn!(
359 "Terminal backing file write error: {}",
360 e
361 );
362 backing_writer = None;
363 }
364 }
365 }
366 }
367
368 // Append raw bytes to log if available (for session restore replay)
369 if let Some(w) = log_writer.as_mut() {
370 if let Err(e) = w.write_all(&buf[..n]) {
371 tracing::warn!("Terminal log write error: {}", e);
372 log_writer = None; // stop logging on error
373 } else if let Err(e) = w.flush() {
374 tracing::warn!("Terminal log flush error: {}", e);
375 log_writer = None;
376 }
377 }
378
379 // Notify main loop to redraw (receiver may be dropped during shutdown).
380 if let Some(ref bridge) = async_bridge {
381 #[allow(clippy::let_underscore_must_use)]
382 let _ = bridge.sender().send(
383 crate::services::async_bridge::AsyncMessage::TerminalOutput {
384 terminal_id,
385 },
386 );
387 }
388 }
389 Err(e) => {
390 tracing::error!("Terminal read error: {}", e);
391 break;
392 }
393 }
394 }
395 alive_clone.store(false, std::sync::atomic::Ordering::Relaxed);
396 // Best-effort flush of log/backing files during teardown.
397 if let Some(mut w) = log_writer {
398 #[allow(clippy::let_underscore_must_use)]
399 let _ = w.flush();
400 }
401 if let Some(mut w) = backing_writer {
402 #[allow(clippy::let_underscore_must_use)]
403 let _ = w.flush();
404 }
405 // Notify that terminal exited (receiver may be dropped during shutdown).
406 if let Some(ref bridge) = async_bridge {
407 #[allow(clippy::let_underscore_must_use)]
408 let _ = bridge.sender().send(
409 crate::services::async_bridge::AsyncMessage::TerminalExited { terminal_id },
410 );
411 }
412 });
413
414 // Spawn writer thread
415 let pty_size_ref = pty_pair.master;
416 thread::spawn(move || {
417 loop {
418 match command_rx.recv() {
419 Ok(TerminalCommand::Write(data)) => {
420 if let Err(e) = master.write_all(&data) {
421 tracing::error!("Terminal write error: {}", e);
422 break;
423 }
424 // Best-effort flush — PTY write errors are handled above.
425 #[allow(clippy::let_underscore_must_use)]
426 let _ = master.flush();
427 }
428 Ok(TerminalCommand::Resize { cols, rows }) => {
429 if let Err(e) = pty_size_ref.resize(PtySize {
430 rows,
431 cols,
432 pixel_width: 0,
433 pixel_height: 0,
434 }) {
435 tracing::warn!("Failed to resize PTY: {}", e);
436 }
437 }
438 Ok(TerminalCommand::Shutdown) | Err(_) => {
439 break;
440 }
441 }
442 }
443 // Best-effort child process cleanup during teardown.
444 #[allow(clippy::let_underscore_must_use)]
445 let _ = child.kill();
446 #[allow(clippy::let_underscore_must_use)]
447 let _ = child.wait();
448 });
449
450 // Create handle
451 Ok(TerminalHandle {
452 state,
453 command_tx,
454 alive,
455 cols,
456 rows,
457 cwd: cwd.clone(),
458 shell,
459 })
460 })();
461
462 let handle = handle_result?;
463
464 self.terminals.insert(id, handle);
465 tracing::info!("Created terminal {:?} ({}x{})", id, cols, rows);
466
467 Ok(id)
468 }
469
470 /// Get a terminal handle by ID
471 pub fn get(&self, id: TerminalId) -> Option<&TerminalHandle> {
472 self.terminals.get(&id)
473 }
474
475 /// Get a mutable terminal handle by ID
476 pub fn get_mut(&mut self, id: TerminalId) -> Option<&mut TerminalHandle> {
477 self.terminals.get_mut(&id)
478 }
479
480 /// Close a terminal
481 pub fn close(&mut self, id: TerminalId) -> bool {
482 if let Some(handle) = self.terminals.remove(&id) {
483 handle.shutdown();
484 true
485 } else {
486 false
487 }
488 }
489
490 /// Get all terminal IDs
491 pub fn terminal_ids(&self) -> Vec<TerminalId> {
492 self.terminals.keys().copied().collect()
493 }
494
495 /// Get count of open terminals
496 pub fn count(&self) -> usize {
497 self.terminals.len()
498 }
499
500 /// Shutdown all terminals
501 pub fn shutdown_all(&mut self) {
502 for (_, handle) in self.terminals.drain() {
503 handle.shutdown();
504 }
505 }
506
507 /// Clean up dead terminals
508 pub fn cleanup_dead(&mut self) -> Vec<TerminalId> {
509 let dead: Vec<TerminalId> = self
510 .terminals
511 .iter()
512 .filter(|(_, h)| !h.is_alive())
513 .map(|(id, _)| *id)
514 .collect();
515
516 for id in &dead {
517 self.terminals.remove(id);
518 }
519
520 dead
521 }
522}
523
524impl Default for TerminalManager {
525 fn default() -> Self {
526 Self::new()
527 }
528}
529
530impl Drop for TerminalManager {
531 fn drop(&mut self) {
532 self.shutdown_all();
533 }
534}
535
536/// Detect the user's shell
537pub fn detect_shell() -> String {
538 // Try $SHELL environment variable first
539 if let Ok(shell) = std::env::var("SHELL") {
540 if !shell.is_empty() {
541 return shell;
542 }
543 }
544
545 // Fall back to platform defaults
546 #[cfg(unix)]
547 {
548 "/bin/sh".to_string()
549 }
550 #[cfg(windows)]
551 {
552 // On Windows, prefer PowerShell for better ConPTY and ANSI escape support
553 // Check for PowerShell Core (pwsh) first, then Windows PowerShell
554 let powershell_paths = [
555 "pwsh.exe",
556 "powershell.exe",
557 r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
558 ];
559
560 for ps in &powershell_paths {
561 if std::path::Path::new(ps).exists() || which_exists(ps) {
562 return ps.to_string();
563 }
564 }
565
566 // Fall back to COMSPEC (cmd.exe)
567 std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
568 }
569}
570
571/// Check if command exists in PATH (Windows)
572#[cfg(windows)]
573fn which_exists(cmd: &str) -> bool {
574 if let Ok(path_var) = std::env::var("PATH") {
575 for path in path_var.split(';') {
576 let full_path = std::path::Path::new(path).join(cmd);
577 if full_path.exists() {
578 return true;
579 }
580 }
581 }
582 false
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn test_terminal_id_display() {
591 let id = TerminalId(42);
592 assert_eq!(format!("{}", id), "Terminal-42");
593 }
594
595 #[test]
596 fn test_detect_shell() {
597 let shell = detect_shell();
598 assert!(!shell.is_empty());
599 }
600}