Skip to main content

Mountain/Environment/
TerminalProvider.rs

1//! # TerminalProvider (Environment)
2//!
3//! Implements the `TerminalProvider` trait for the `MountainEnvironment`.
4//! Contains the core logic for managing integrated terminal instances,
5//! including creating native pseudo-terminals (PTYs) and handling their I/O.
6//!
7//! ## Terminal architecture
8//!
9//! 1. **PTY creation** - `portable-pty` opens a native PTY pair.
10//! 2. **Process spawning** - shell spawned as child of PTY slave.
11//! 3. **I/O streaming** - dedicated `tokio::spawn` tasks for input, output, and
12//!    process exit; each terminal gets its own tasks.
13//! 4. **IPC fan-out** - PTY output is sent in two directions:
14//!    - Cocoon extension host via `$acceptTerminalProcessData` (gRPC)
15//!    - Sky webview via `SkyEvent::TerminalData` (Tauri emit)
16//! 5. **State management** -
17//!    `ApplicationState.Feature.Terminals.ActiveTerminals` keyed by `u64`
18//!    terminal ID.
19//!
20//! ## Terminal lifecycle
21//!
22//! 1. `CreateTerminal` - create PTY, spawn shell, start I/O tasks, emit
23//!    `TerminalCreate` (deferred 120 ms to avoid a race with `_ptys.set`).
24//! 2. `SendTextToTerminal` - write user input to PTY via mpsc channel.
25//! 3. `ResizeTerminal` - call `MasterPty::resize` via `spawn_blocking`.
26//! 4. `ShowTerminal` / `HideTerminal` - emit UI events to Sky.
27//! 5. `GetTerminalProcessId` - read OS PID from `TerminalStateDTO`.
28//! 6. `DisposeTerminal` - drop `Arc<TerminalStateDTO>`; PTY close kills shell.
29//!
30//! ## Shell detection
31//!
32//! - **Windows**: `powershell.exe`
33//! - **macOS / Linux**: `$SHELL`, fallback to `sh`
34//!
35//! Custom shell paths can be provided via terminal options.
36//!
37//! ## Output replay buffer
38//!
39//! Each terminal keeps a ring buffer of up to 64 KB of recent PTY output
40//! (`TERMINAL_OUTPUT_BUFFER`). On `sky:replay-events` the buffered bytes are
41//! replayed to Sky, covering the ~1 500 ms gap between shell spawn and
42//! SkyBridge listener install during workbench boot.
43//!
44//! ## VS Code reference
45//!
46//! Patterns from VS Code's integrated terminal:
47//! - `vs/workbench/contrib/terminal/node/terminalProcess.ts`
48//! - `vs/platform/terminal/node/ptyService.ts`
49
50use std::{env, io::Write, sync::Arc};
51
52use CommonLibrary::{
53	Environment::Requires::Requires,
54	Error::CommonError::CommonError,
55	IPC::{IPCProvider::IPCProvider, SkyEvent::SkyEvent},
56	Terminal::TerminalProvider::TerminalProvider,
57};
58use async_trait::async_trait;
59use portable_pty::{CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
60use serde_json::{Value, json};
61use tauri::Emitter;
62use tokio::sync::mpsc as TokioMPSC;
63
64use super::{MountainEnvironment::MountainEnvironment, Utility};
65use crate::{ApplicationState::DTO::TerminalStateDTO::TerminalStateDTO, IPC::SkyEmit::LogSkyEmit, dev_log};
66
67// Per-terminal recent-output buffer. The PTY reader task races SkyBridge's
68// `listen("sky://terminal/data", ...)` install: in the bundled-electron
69// profile, the shell's first prompt + any startup chatter (zsh's MOTD,
70// `direnv` exports, fish's greeting, …) fires within ~50 ms of
71// `localPty:createProcess` while Sky's bundle is still parsing for ~1500 ms.
72// Without buffering, those bytes vanish and the user sees an empty pane
73// until they type something to coax fresh output. We buffer up to
74// `MAX_BUFFERED_BYTES` per terminal and replay on `sky:replay-events`.
75//
76// The buffer is bounded; on overflow we drop oldest bytes (keep the most
77// recent suffix). 64 KB is enough for ~600 lines of typical zsh/bash
78// startup; tail-cropping preserves the prompt the user actually needs to
79// see.
80const MAX_BUFFERED_BYTES:usize = 64 * 1024;
81
82static TERMINAL_OUTPUT_BUFFER:std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<u64, Vec<u8>>>> =
83	std::sync::OnceLock::new();
84
85fn TerminalOutputBuffer() -> &'static std::sync::Mutex<std::collections::HashMap<u64, Vec<u8>>> {
86	TERMINAL_OUTPUT_BUFFER.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
87}
88
89pub fn AppendTerminalOutput(TerminalId:u64, Bytes:&[u8]) {
90	if let Ok(mut Map) = TerminalOutputBuffer().lock() {
91		let Entry = Map.entry(TerminalId).or_insert_with(Vec::new);
92
93		Entry.extend_from_slice(Bytes);
94
95		// Drop oldest if over cap. Keep the trailing MAX_BUFFERED_BYTES so
96		// the prompt + most-recent context survive.
97		if Entry.len() > MAX_BUFFERED_BYTES {
98			let DropCount = Entry.len() - MAX_BUFFERED_BYTES;
99
100			Entry.drain(..DropCount);
101		}
102	}
103}
104
105pub fn DrainTerminalOutputBuffer() -> Vec<(u64, Vec<u8>)> {
106	if let Ok(Map) = TerminalOutputBuffer().lock() {
107		Map.iter().map(|(K, V)| (*K, V.clone())).collect()
108	} else {
109		Vec::new()
110	}
111}
112
113pub fn RemoveTerminalOutputBuffer(TerminalId:u64) {
114	if let Ok(mut Map) = TerminalOutputBuffer().lock() {
115		Map.remove(&TerminalId);
116	}
117}
118
119// TODO: terminal profiles + env var management, resize handling (PtySize),
120// colour schemes, bell / visual notifications, buffer scroll + history,
121// in-pane search, reconnect for crashed processes, tab + split-view,
122// decoration (cwd indicator), shell integration (fish/zsh/bash), ANSI escape
123// handling, clipboard ops, link detection + navigation, process tree, font
124// config, UTF-8 / Unicode, timeout + idle detection, multi-instance mgmt.
125#[async_trait]
126impl TerminalProvider for MountainEnvironment {
127	/// Creates a new terminal instance, spawns a PTY, and manages its I/O.
128	async fn CreateTerminal(&self, OptionsValue:Value) -> Result<Value, CommonError> {
129		let TerminalIdentifier = self.ApplicationState.GetNextTerminalIdentifier();
130
131		let DefaultShell = if cfg!(windows) {
132			"powershell.exe".to_string()
133		} else {
134			env::var("SHELL").unwrap_or_else(|_| "sh".to_string())
135		};
136
137		let Name = OptionsValue
138			.get("name")
139			.and_then(Value::as_str)
140			.unwrap_or("terminal")
141			.to_string();
142
143		dev_log!(
144			"terminal",
145			"[TerminalProvider] Creating terminal ID: {}, Name: '{}'",
146			TerminalIdentifier,
147			Name
148		);
149
150		let mut TerminalState = TerminalStateDTO::Create(TerminalIdentifier, Name.clone(), &OptionsValue, DefaultShell)
151			.map_err(|e| {
152				CommonError::ConfigurationLoad { Description:format!("Failed to create terminal state: {}", e) }
153			})?;
154
155		let PtySystem = NativePtySystem::default();
156
157		let PtyPair = PtySystem
158			.openpty(PtySize::default())
159			.map_err(|Error| CommonError::IPCError { Description:format!("Failed to open PTY: {}", Error) })?;
160
161		let mut Command = CommandBuilder::new(&TerminalState.ShellPath);
162
163		Command.args(&TerminalState.ShellArguments);
164
165		if let Some(CWD) = &TerminalState.CurrentWorkingDirectory {
166			Command.cwd(CWD);
167		}
168
169		let mut ChildProcess = PtyPair.slave.spawn_command(Command).map_err(|Error| {
170			CommonError::IPCError { Description:format!("Failed to spawn shell process: {}", Error) }
171		})?;
172
173		TerminalState.OSProcessIdentifier = ChildProcess.process_id();
174
175		let mut PTYWriter = PtyPair.master.take_writer().map_err(|Error| {
176			CommonError::FileSystemIO {
177				Path:"pty master".into(),
178
179				Description:format!("Failed to take PTY writer: {}", Error),
180			}
181		})?;
182
183		let (InputTransmitter, mut InputReceiver) = TokioMPSC::channel::<String>(32);
184
185		TerminalState.PTYInputTransmitter = Some(InputTransmitter);
186
187		let TermIDForInput = TerminalIdentifier;
188
189		tokio::spawn(async move {
190			while let Some(Data) = InputReceiver.recv().await {
191				if let Err(Error) = PTYWriter.write_all(Data.as_bytes()) {
192					dev_log!(
193						"terminal",
194						"error: [TerminalProvider] PTY write failed for ID {}: {}",
195						TermIDForInput,
196						Error
197					);
198
199					break;
200				}
201			}
202		});
203
204		let mut PTYReader = PtyPair.master.try_clone_reader().map_err(|Error| {
205			CommonError::FileSystemIO {
206				Path:"pty master".into(),
207
208				Description:format!("Failed to clone PTY reader: {}", Error),
209			}
210		})?;
211
212		// Keep the master PTY alive past `CreateTerminal` so `ResizeTerminal`
213		// can call `resize()` on it and so dropping it during `DisposeTerminal`
214		// tears the shell down cleanly.
215		let PTYMasterHandle:crate::ApplicationState::DTO::TerminalStateDTO::PtyMasterHandle =
216			Arc::new(std::sync::Mutex::new(PtyPair.master));
217
218		TerminalState.PTYMaster = Some(PTYMasterHandle);
219
220		let IPCProvider:Arc<dyn IPCProvider> = self.Require();
221
222		let TermIDForOutput = TerminalIdentifier;
223
224		let AppHandleForOutput = self.ApplicationHandle.clone();
225
226		tokio::spawn(async move {
227			let mut Buffer = [0u8; 8192];
228
229			loop {
230				match PTYReader.read(&mut Buffer) {
231					Ok(count) if count > 0 => {
232						// Buffer the bytes for replay-on-late-listener. The
233						// SkyBridge install completes ~1500 ms after Cocoon
234						// activates, and the shell's first prompt fires
235						// immediately after `spawn_command`. Without a
236						// buffer the prompt is silently lost and the user
237						// sees an empty terminal pane until they type.
238						AppendTerminalOutput(TermIDForOutput, &Buffer[..count]);
239
240						let DataString = String::from_utf8_lossy(&Buffer[..count]).to_string();
241
242						// Fan out in two directions so both consumers see
243						// the bytes:
244						//   1. Cocoon's extension host (via gRPC) - lets
245						//      `vscode.window.onDidWriteTerminalData` and the SCM
246						//      `$acceptTerminalProcessData` chain continue to function.
247						//   2. Sky's webview (via Tauri event) - the UI xterm renderer subscribes to
248						//      `sky://terminal/data` and draws the bytes into the user-visible terminal
249						//      panel.
250						// Without the Tauri emit the user sees a terminal
251						// panel open but no shell output because gRPC-only
252						// delivery bypasses the webview entirely (BATCH-19
253						// Part B).
254						let Payload = json!([TermIDForOutput, DataString.clone()]);
255						if let Err(Error) = IPCProvider
256							.SendNotificationToSideCar(
257								"cocoon-main".into(),
258								"$acceptTerminalProcessData".into(),
259								Payload,
260							)
261							.await
262						{
263							dev_log!(
264								"terminal",
265								"warn: [TerminalProvider] Failed to send process data for ID {}: {}",
266								TermIDForOutput,
267								Error
268							);
269						}
270
271						if let Err(Error) = AppHandleForOutput.emit(
272							SkyEvent::TerminalData.AsStr(),
273							json!({
274								"id": TermIDForOutput,
275								"data": DataString,
276							}),
277						) {
278							dev_log!(
279								"terminal",
280								"warn: [TerminalProvider] sky://terminal/data emit failed for ID {}: {}",
281								TermIDForOutput,
282								Error
283							);
284						}
285					},
286
287					// Break on Ok(0) or Err
288					_ => break,
289				}
290			}
291		});
292
293		let TermIDForExit = TerminalIdentifier;
294
295		// BATCH-19 Part B: capture the PID before `ChildProcess` is moved into
296		// the exit-watcher task so the exit log line can correlate with the
297		// spawn log (`[TerminalProvider] localPty:spawn OK id=N pid=M`). Also
298		// surface the actual exit status code - previously discarded via
299		// `let _exit_status = …`, which meant the log could only say "has
300		// exited" without distinguishing a clean `exit 0`, `echo hi; exit`
301		// flow from a crash. That distinction is what the BATCH-19 smoke test
302		// needs to confirm the shell really ran and returned.
303		let PidForExit = ChildProcess.process_id();
304
305		let EnvironmentClone = self.clone();
306
307		tokio::spawn(async move {
308			let ExitStatus = ChildProcess.wait();
309
310			// portable-pty's `Child::wait()` returns `io::Result<ExitStatus>`.
311			// `{:?}` on ExitStatus shows `success` and any captured code
312			// without needing to commit to a specific accessor name (the
313			// crate's exit-status API has varied across versions).
314			let StatusSummary = match &ExitStatus {
315				Ok(Code) => format!("exited {:?}", Code),
316				Err(Error) => format!("wait failed: {}", Error),
317			};
318
319			dev_log!(
320				"terminal",
321				"[TerminalProvider] Process for terminal ID {} pid={:?} {}",
322				TermIDForExit,
323				PidForExit,
324				StatusSummary
325			);
326
327			let IPCProvider:Arc<dyn IPCProvider> = EnvironmentClone.Require();
328
329			if let Err(Error) = IPCProvider
330				.SendNotificationToSideCar(
331					"cocoon-main".into(),
332					"$acceptTerminalProcessExit".into(),
333					json!([TermIDForExit]),
334				)
335				.await
336			{
337				dev_log!(
338					"terminal",
339					"warn: [TerminalProvider] Failed to send process exit notification for ID {}: {}",
340					TermIDForExit,
341					Error
342				);
343			}
344
345			// Clean up the terminal from the state
346			if let Ok(mut Guard) = EnvironmentClone.ApplicationState.Feature.Terminals.ActiveTerminals.lock() {
347				Guard.remove(&TermIDForExit);
348			}
349			// Drop the recent-output replay buffer; nothing left to replay
350			// after the shell has exited.
351			RemoveTerminalOutputBuffer(TermIDForExit);
352
353			// Tell Sky the xterm panel should drop - mirrors the `sky://`
354			// create emit above. Without this, the UI keeps a ghost panel
355			// after the shell exits (user types `exit` and the pane still
356			// lingers until the next render cycle).
357			if let Err(Error) = LogSkyEmit(
358				&EnvironmentClone.ApplicationHandle,
359				SkyEvent::TerminalExit.AsStr(),
360				json!({ "id": TermIDForExit }),
361			) {
362				dev_log!(
363					"terminal",
364					"warn: [TerminalProvider] sky://terminal/exit emit failed for ID {}: {}",
365					TermIDForExit,
366					Error
367				);
368			}
369		});
370
371		self.ApplicationState
372			.Feature
373			.Terminals
374			.ActiveTerminals
375			.lock()
376			.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
377			.insert(TerminalIdentifier, Arc::new(std::sync::Mutex::new(TerminalState.clone())));
378
379		// BATCH-19 Part B: let Sky render the new terminal panel without
380		// waiting for Cocoon to round-trip a notification. The `sky://` event
381		// channel is already how ShowTerminal / HideTerminal talk to the UI.
382		//
383		// RACE FIX: emit on a deferred tokio task (~120 ms) instead of
384		// synchronously. The workbench's `LocalTerminalBackend.createProcess`
385		// flow is:
386		//   1. await this._proxy.createProcess(...)   // RPC IN-FLIGHT
387		//   2. const pty = new LocalPty(id, …)        // POST-await
388		//   3. this._ptys.set(id, pty)                // POST-await
389		// The patched `_connectToDirectProxy` listener for
390		// `_localPtyService.onProcessReady` does
391		// `this._ptys.get(e.id)?.handleReady(e.event)`. If we emit
392		// synchronously while CreateTerminal is still inside step (1),
393		// the Tauri event fires before step (3) - `_ptys.get(id)` returns
394		// `undefined`, `handleReady` is skipped, `BasePty._onProcessReady`
395		// never fires, `processManager._onProcessReady` never fires,
396		// `ptyProcessReady` never resolves - and every `processManager.
397		// write(data)` call (which `terminalInstance._handleOnData`
398		// `await`s) hangs forever. The user sees the panel render but
399		// every keystroke is silently dropped because `LocalPty.input`
400		// is never reached. A 120 ms delay gives the RPC response
401		// roundtrip + `_ptys.set` plenty of headroom on real hardware.
402		// Same race applies to `sky://terminal/data` for the shell's
403		// first prompt - the existing `AppendTerminalOutput` replay
404		// buffer covers data, but the create event needs explicit
405		// deferral because there's no replay path for ready.
406		let CreateAppHandle = self.ApplicationHandle.clone();
407
408		let CreateTermId = TerminalIdentifier;
409
410		let CreateName = Name.clone();
411
412		let CreatePid = TerminalState.OSProcessIdentifier;
413
414		tokio::spawn(async move {
415			tokio::time::sleep(std::time::Duration::from_millis(120)).await;
416			let CreatePayload = json!({
417				"id": CreateTermId,
418				"name": CreateName,
419				"pid": CreatePid,
420			});
421			// `LogSkyEmit` makes the deferred emit visible under
422			// `[DEV:SKY-EMIT]` so the next log dissection can confirm
423			// the deferral landed (and how many `localPty:input` calls
424			// arrived afterwards). The bare `.emit()` we replaced was
425			// invisible to the histogram.
426			if let Err(Error) = LogSkyEmit(&CreateAppHandle, SkyEvent::TerminalCreate.AsStr(), CreatePayload) {
427				dev_log!(
428					"terminal",
429					"warn: [TerminalProvider] sky://terminal/create emit failed for ID {}: {}",
430					CreateTermId,
431					Error
432				);
433			}
434		});
435
436		dev_log!(
437			"terminal",
438			"[TerminalProvider] localPty:spawn OK id={} pid={:?}",
439			TerminalIdentifier,
440			TerminalState.OSProcessIdentifier
441		);
442
443		Ok(json!({ "id": TerminalIdentifier, "name": Name, "pid": TerminalState.OSProcessIdentifier }))
444	}
445
446	async fn SendTextToTerminal(&self, TerminalId:u64, Text:String) -> Result<(), CommonError> {
447		dev_log!("terminal", "[TerminalProvider] Sending text to terminal ID: {}", TerminalId);
448
449		let SenderOption = {
450			let TerminalsGuard = self
451				.ApplicationState
452				.Feature
453				.Terminals
454				.ActiveTerminals
455				.lock()
456				.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
457
458			TerminalsGuard
459				.get(&TerminalId)
460				.and_then(|TerminalArc| TerminalArc.lock().ok())
461				.and_then(|TerminalStateGuard| TerminalStateGuard.PTYInputTransmitter.clone())
462		};
463
464		if let Some(Sender) = SenderOption {
465			Sender
466				.send(Text)
467				.await
468				.map_err(|Error| CommonError::IPCError { Description:Error.to_string() })
469		} else {
470			Err(CommonError::IPCError {
471				Description:format!("Terminal with ID {} not found or has no input channel.", TerminalId),
472			})
473		}
474	}
475
476	async fn DisposeTerminal(&self, TerminalId:u64) -> Result<(), CommonError> {
477		dev_log!("terminal", "[TerminalProvider] Disposing terminal ID: {}", TerminalId);
478
479		let TerminalArc = self
480			.ApplicationState
481			.Feature
482			.Terminals
483			.ActiveTerminals
484			.lock()
485			.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
486			.remove(&TerminalId);
487
488		if let Some(TerminalArc) = TerminalArc {
489			// Dropping the PTY master's writer and reader handles will signal the
490			// underlying process to terminate.
491			drop(TerminalArc);
492		}
493
494		Ok(())
495	}
496
497	async fn ShowTerminal(&self, TerminalId:u64, PreserveFocus:bool) -> Result<(), CommonError> {
498		dev_log!("terminal", "[TerminalProvider] Showing terminal ID: {}", TerminalId);
499
500		self.ApplicationHandle
501			.emit(
502				SkyEvent::TerminalShow.AsStr(),
503				json!({ "id": TerminalId, "preserveFocus": PreserveFocus }),
504			)
505			.map_err(|Error| CommonError::UserInterfaceInteraction { Reason:Error.to_string() })
506	}
507
508	async fn HideTerminal(&self, TerminalId:u64) -> Result<(), CommonError> {
509		dev_log!("terminal", "[TerminalProvider] Hiding terminal ID: {}", TerminalId);
510
511		// Low-frequency lifecycle event - safe to route through
512		// `LogSkyEmit` for histogram visibility.
513		LogSkyEmit(
514			&self.ApplicationHandle,
515			SkyEvent::TerminalHide.AsStr(),
516			json!({ "id": TerminalId }),
517		)
518		.map_err(|Error| CommonError::UserInterfaceInteraction { Reason:Error.to_string() })
519	}
520
521	async fn GetTerminalProcessId(&self, TerminalId:u64) -> Result<Option<u32>, CommonError> {
522		let TerminalsGuard = self
523			.ApplicationState
524			.Feature
525			.Terminals
526			.ActiveTerminals
527			.lock()
528			.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
529
530		Ok(TerminalsGuard
531			.get(&TerminalId)
532			.and_then(|t| t.lock().ok().and_then(|g| g.OSProcessIdentifier)))
533	}
534
535	async fn ResizeTerminal(&self, TerminalId:u64, Columns:u16, Rows:u16) -> Result<(), CommonError> {
536		if Columns == 0 || Rows == 0 {
537			return Err(CommonError::InvalidArgument {
538				ArgumentName:"Columns/Rows".to_string(),
539				Reason:format!("Columns and Rows must be ≥ 1 (got {}×{})", Columns, Rows),
540			});
541		}
542
543		// Pull the shared master-PTY handle out of the state lock before touching
544		// it so we never hold the outer terminals map while performing IO.
545		let MasterOption = {
546			let TerminalsGuard = self
547				.ApplicationState
548				.Feature
549				.Terminals
550				.ActiveTerminals
551				.lock()
552				.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
553
554			TerminalsGuard
555				.get(&TerminalId)
556				.and_then(|TerminalArc| TerminalArc.lock().ok())
557				.and_then(|TerminalStateGuard| TerminalStateGuard.PTYMaster.clone())
558		};
559
560		let Master = MasterOption.ok_or_else(|| {
561			CommonError::IPCError {
562				Description:format!("Terminal with ID {} not found or has no PTY master handle.", TerminalId),
563			}
564		})?;
565
566		let Size = PtySize { rows:Rows, cols:Columns, pixel_width:0, pixel_height:0 };
567
568		// Method resolution walks through MutexGuard → Box → dyn MasterPty,
569		// so `Guard.resize(...)` dispatches straight to the trait impl. Keep
570		// the call inside `spawn_blocking` even though portable-pty's resize
571		// is nominally fast - SIGWINCH delivery can stall briefly when the
572		// child shell is ptrace-frozen or mid-syscall.
573		tokio::task::spawn_blocking(move || {
574			let Guard = Master.lock().map_err(|_| "PTY master mutex poisoned".to_string())?;
575			Guard.resize(Size).map_err(|Error| Error.to_string())
576		})
577		.await
578		.map_err(|Error| CommonError::IPCError { Description:format!("resize join error: {}", Error) })?
579		.map_err(|Error| CommonError::IPCError { Description:format!("PTY resize failed: {}", Error) })?;
580
581		dev_log!(
582			"terminal",
583			"[TerminalProvider] Resized terminal ID {} to {}×{}",
584			TerminalId,
585			Columns,
586			Rows
587		);
588
589		Ok(())
590	}
591}