Skip to main content

DevelopmentNodeEnvironment_MicrosoftVSCodeDependency_22NodeVersion_Bundle_Clean_Debug_ElectronProfile_EsbuildCompiler_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(crate) 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 Fn() -> 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(crate) 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		// Inherit the parent process environment so the spawned shell
164		// sees `PATH`, `HOME`, etc. unchanged from Mountain's view. The
165		// EnvironmentVariableCollection pass below then mutates on top
166		// of this snapshot, matching upstream's `mergedCollection.apply`
167		// behaviour where extension-supplied env stacks on the
168		// inherited base.
169		let mut MergedEnv:std::collections::HashMap<String, String> = std::env::vars().collect();
170
171		// Apply every extension's registered EnvironmentVariableCollection
172		// mutations BEFORE shell integration so the integration's env
173		// (which extensions never see) takes precedence on conflicts -
174		// no extension should be able to break the OSC 633 inputs.
175		super::TerminalEnvCollection::ApplyToEnv(&mut MergedEnv);
176
177		// Apply shell integration injection (OSC 633 command tracking).
178		// Mutates args and env vars before the PTY is spawned; no-op when
179		// the shell is unsupported or LAND_SHELL_INTEGRATION=0 is set.
180		if let Some(Injection) =
181			super::Terminal::ShellIntegration::Compute(&self.ApplicationHandle, &TerminalState.ShellPath)
182		{
183			for (Key, Val) in Injection.EnvVars {
184				MergedEnv.insert(Key, Val);
185			}
186			// Prepend-args come before any user-supplied args (rare but
187			// important for interpreters that parse flags positionally).
188			let mut AllArgs = Injection.PrependArgs;
189			AllArgs.extend(TerminalState.ShellArguments.iter().cloned());
190			AllArgs.extend(Injection.AppendArgs);
191			Command.args(&AllArgs);
192		} else {
193			Command.args(&TerminalState.ShellArguments);
194		}
195
196		// Apply the merged env to the child process. `portable-pty`'s
197		// CommandBuilder doesn't have `envs(IntoIterator)`, so iterate.
198		for (Key, Val) in &MergedEnv {
199			Command.env(Key, Val);
200		}
201
202		if let Some(CWD) = &TerminalState.CurrentWorkingDirectory {
203			Command.cwd(CWD);
204		}
205
206		let mut ChildProcess = PtyPair.slave.spawn_command(Command).map_err(|Error| {
207			CommonError::IPCError { Description:format!("Failed to spawn shell process: {}", Error) }
208		})?;
209
210		TerminalState.OSProcessIdentifier = ChildProcess.process_id();
211
212		let mut PTYWriter = PtyPair.master.take_writer().map_err(|Error| {
213			CommonError::FileSystemIO {
214				Path:"pty master".into(),
215
216				Description:format!("Failed to take PTY writer: {}", Error),
217			}
218		})?;
219
220		let (InputTransmitter, mut InputReceiver) = TokioMPSC::channel::<String>(32);
221
222		TerminalState.PTYInputTransmitter = Some(InputTransmitter);
223
224		let TermIDForInput = TerminalIdentifier;
225
226		tokio::spawn(async move {
227			while let Some(Data) = InputReceiver.recv().await {
228				if let Err(Error) = PTYWriter.write_all(Data.as_bytes()) {
229					dev_log!(
230						"terminal",
231						"error: [TerminalProvider] PTY write failed for ID {}: {}",
232						TermIDForInput,
233						Error
234					);
235
236					break;
237				}
238			}
239		});
240
241		let mut PTYReader = PtyPair.master.try_clone_reader().map_err(|Error| {
242			CommonError::FileSystemIO {
243				Path:"pty master".into(),
244
245				Description:format!("Failed to clone PTY reader: {}", Error),
246			}
247		})?;
248
249		// Keep the master PTY alive past `CreateTerminal` so `ResizeTerminal`
250		// can call `resize()` on it and so dropping it during `DisposeTerminal`
251		// tears the shell down cleanly.
252		let PTYMasterHandle:crate::ApplicationState::DTO::TerminalStateDTO::PtyMasterHandle =
253			Arc::new(std::sync::Mutex::new(PtyPair.master));
254
255		TerminalState.PTYMaster = Some(PTYMasterHandle);
256
257		let IPCProvider:Arc<dyn IPCProvider> = self.Require();
258
259		let TermIDForOutput = TerminalIdentifier;
260
261		let AppHandleForOutput = self.ApplicationHandle.clone();
262
263		tokio::spawn(async move {
264			let mut Buffer = [0u8; 8192];
265
266			loop {
267				match PTYReader.read(&mut Buffer) {
268					Ok(count) if count > 0 => {
269						// Buffer the bytes for replay-on-late-listener. The
270						// SkyBridge install completes ~1500 ms after Cocoon
271						// activates, and the shell's first prompt fires
272						// immediately after `spawn_command`. Without a
273						// buffer the prompt is silently lost and the user
274						// sees an empty terminal pane until they type.
275						AppendTerminalOutput(TermIDForOutput, &Buffer[..count]);
276
277						let DataString = String::from_utf8_lossy(&Buffer[..count]).to_string();
278
279						// Fan out in two directions so both consumers see
280						// the bytes:
281						//   1. Cocoon's extension host (via gRPC) - lets
282						//      `vscode.window.onDidWriteTerminalData` and the SCM
283						//      `$acceptTerminalProcessData` chain continue to function.
284						//   2. Sky's webview (via Tauri event) - the UI xterm renderer subscribes to
285						//      `sky://terminal/data` and draws the bytes into the user-visible terminal
286						//      panel.
287						// Without the Tauri emit the user sees a terminal
288						// panel open but no shell output because gRPC-only
289						// delivery bypasses the webview entirely (BATCH-19
290						// Part B).
291						let Payload = json!([TermIDForOutput, DataString.clone()]);
292						if let Err(Error) = IPCProvider
293							.SendNotificationToSideCar(
294								"cocoon-main".into(),
295								"$acceptTerminalProcessData".into(),
296								Payload,
297							)
298							.await
299						{
300							dev_log!(
301								"terminal",
302								"warn: [TerminalProvider] Failed to send process data for ID {}: {}",
303								TermIDForOutput,
304								Error
305							);
306						}
307
308						if let Err(Error) = AppHandleForOutput.emit(
309							SkyEvent::TerminalData.AsStr(),
310							json!({
311								"id": TermIDForOutput,
312								"data": DataString,
313							}),
314						) {
315							dev_log!(
316								"terminal",
317								"warn: [TerminalProvider] sky://terminal/data emit failed for ID {}: {}",
318								TermIDForOutput,
319								Error
320							);
321						}
322					},
323
324					// Break on Ok(0) or Err
325					_ => break,
326				}
327			}
328		});
329
330		let TermIDForExit = TerminalIdentifier;
331
332		// BATCH-19 Part B: capture the PID before `ChildProcess` is moved into
333		// the exit-watcher task so the exit log line can correlate with the
334		// spawn log (`[TerminalProvider] localPty:spawn OK id=N pid=M`). Also
335		// surface the actual exit status code - previously discarded via
336		// `let _exit_status = …`, which meant the log could only say "has
337		// exited" without distinguishing a clean `exit 0`, `echo hi; exit`
338		// flow from a crash. That distinction is what the BATCH-19 smoke test
339		// needs to confirm the shell really ran and returned.
340		let PidForExit = ChildProcess.process_id();
341
342		let EnvironmentClone = self.clone();
343
344		tokio::spawn(async move {
345			let ExitStatus = ChildProcess.wait();
346
347			// portable-pty's `Child::wait()` returns `io::Result<ExitStatus>`.
348			// `{:?}` on ExitStatus shows `success` and any captured code
349			// without needing to commit to a specific accessor name (the
350			// crate's exit-status API has varied across versions).
351			let StatusSummary = match &ExitStatus {
352				Ok(Code) => format!("exited {:?}", Code),
353				Err(Error) => format!("wait failed: {}", Error),
354			};
355
356			dev_log!(
357				"terminal",
358				"[TerminalProvider] Process for terminal ID {} pid={:?} {}",
359				TermIDForExit,
360				PidForExit,
361				StatusSummary
362			);
363
364			let IPCProvider:Arc<dyn IPCProvider> = EnvironmentClone.Require();
365
366			if let Err(Error) = IPCProvider
367				.SendNotificationToSideCar(
368					"cocoon-main".into(),
369					"$acceptTerminalProcessExit".into(),
370					json!([TermIDForExit]),
371				)
372				.await
373			{
374				dev_log!(
375					"terminal",
376					"warn: [TerminalProvider] Failed to send process exit notification for ID {}: {}",
377					TermIDForExit,
378					Error
379				);
380			}
381
382			// Clean up the terminal from the state
383			if let Ok(mut Guard) = EnvironmentClone.ApplicationState.Feature.Terminals.ActiveTerminals.lock() {
384				Guard.remove(&TermIDForExit);
385			}
386			// Drop the recent-output replay buffer; nothing left to replay
387			// after the shell has exited.
388			RemoveTerminalOutputBuffer(TermIDForExit);
389
390			// Tell Sky the xterm panel should drop - mirrors the `sky://`
391			// create emit above. Without this, the UI keeps a ghost panel
392			// after the shell exits (user types `exit` and the pane still
393			// lingers until the next render cycle).
394			if let Err(Error) = LogSkyEmit(
395				&EnvironmentClone.ApplicationHandle,
396				SkyEvent::TerminalExit.AsStr(),
397				json!({ "id": TermIDForExit }),
398			) {
399				dev_log!(
400					"terminal",
401					"warn: [TerminalProvider] sky://terminal/exit emit failed for ID {}: {}",
402					TermIDForExit,
403					Error
404				);
405			}
406
407			// B6: Notify Cocoon so vscode.window.terminals removes the entry.
408			// Cocoon's NotificationHandler maps `$acceptTerminalClosed` →
409			// filters `__terminals` by id.
410			let _ = crate::Vine::Client::SendNotification::Fn(
411				"cocoon-main".to_string(),
412				"$acceptTerminalClosed".to_string(),
413				serde_json::json!({ "id": TermIDForExit }),
414			)
415			.await;
416		});
417
418		self.ApplicationState
419			.Feature
420			.Terminals
421			.ActiveTerminals
422			.lock()
423			.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
424			.insert(TerminalIdentifier, Arc::new(std::sync::Mutex::new(TerminalState.clone())));
425
426		// BATCH-19 Part B: let Sky render the new terminal panel without
427		// waiting for Cocoon to round-trip a notification. The `sky://` event
428		// channel is already how ShowTerminal / HideTerminal talk to the UI.
429		//
430		// RACE FIX: emit on a deferred tokio task (~120 ms) instead of
431		// synchronously. The workbench's `LocalTerminalBackend.createProcess`
432		// flow is:
433		//   1. await this._proxy.createProcess(...)   // RPC IN-FLIGHT
434		//   2. const pty = new LocalPty(id, …)        // POST-await
435		//   3. this._ptys.set(id, pty)                // POST-await
436		// The patched `_connectToDirectProxy` listener for
437		// `_localPtyService.onProcessReady` does
438		// `this._ptys.get(e.id)?.handleReady(e.event)`. If we emit
439		// synchronously while CreateTerminal is still inside step (1),
440		// the Tauri event fires before step (3) - `_ptys.get(id)` returns
441		// `undefined`, `handleReady` is skipped, `BasePty._onProcessReady`
442		// never fires, `processManager._onProcessReady` never fires,
443		// `ptyProcessReady` never resolves - and every `processManager.
444		// write(data)` call (which `terminalInstance._handleOnData`
445		// `await`s) hangs forever. The user sees the panel render but
446		// every keystroke is silently dropped because `LocalPty.input`
447		// is never reached. A 120 ms delay gives the RPC response
448		// roundtrip + `_ptys.set` plenty of headroom on real hardware.
449		// Same race applies to `sky://terminal/data` for the shell's
450		// first prompt - the existing `AppendTerminalOutput` replay
451		// buffer covers data, but the create event needs explicit
452		// deferral because there's no replay path for ready.
453		let CreateAppHandle = self.ApplicationHandle.clone();
454
455		let CreateTermId = TerminalIdentifier;
456
457		let CreateName = Name.clone();
458
459		let CreatePid = TerminalState.OSProcessIdentifier;
460
461		tokio::spawn(async move {
462			// 20 ms: enough for the Tauri invoke round-trip + `_ptys.set(id,pty)`
463			// to complete before `onProcessReady` fires. The original 120 ms was
464			// measured on a slow test machine; modern M-series hardware completes
465			// the full cycle in <5 ms. 20 ms gives 4× headroom.
466			tokio::time::sleep(std::time::Duration::from_millis(20)).await;
467			let CreatePayload = json!({
468				"id": CreateTermId,
469				"name": CreateName.clone(),
470				"pid": CreatePid,
471			});
472			// `LogSkyEmit` makes the deferred emit visible under
473			// `[DEV:SKY-EMIT]` so the next log dissection can confirm
474			// the deferral landed (and how many `localPty:input` calls
475			// arrived afterwards). The bare `.emit()` we replaced was
476			// invisible to the histogram.
477			if let Err(Error) = LogSkyEmit(&CreateAppHandle, SkyEvent::TerminalCreate.AsStr(), CreatePayload.clone()) {
478				dev_log!(
479					"terminal",
480					"warn: [TerminalProvider] sky://terminal/create emit failed for ID {}: {}",
481					CreateTermId,
482					Error
483				);
484			}
485
486			// B6: Also notify Cocoon so vscode.window.terminals stays current
487			// when terminals are created from the UI rather than via the
488			// extension API (createTerminal()). Cocoon's NotificationHandler
489			// maps `$acceptTerminalOpened` → pushes a stub to `__terminals`.
490			if let Err(E) = crate::Vine::Client::SendNotification::Fn(
491				"cocoon-main".to_string(),
492				"$acceptTerminalOpened".to_string(),
493				serde_json::json!({ "id": CreateTermId, "name": CreateName, "pid": CreatePid }),
494			)
495			.await
496			{
497				dev_log!(
498					"terminal",
499					"warn: [TerminalProvider] $acceptTerminalOpened notify failed ID={}: {}",
500					CreateTermId,
501					E
502				);
503			}
504		});
505
506		dev_log!(
507			"terminal",
508			"[TerminalProvider] localPty:spawn OK id={} pid={:?}",
509			TerminalIdentifier,
510			TerminalState.OSProcessIdentifier
511		);
512
513		Ok(json!({ "id": TerminalIdentifier, "name": Name, "pid": TerminalState.OSProcessIdentifier }))
514	}
515
516	async fn SendTextToTerminal(&self, TerminalId:u64, Text:String) -> Result<(), CommonError> {
517		dev_log!("terminal", "[TerminalProvider] Sending text to terminal ID: {}", TerminalId);
518
519		let SenderOption = {
520			let TerminalsGuard = self
521				.ApplicationState
522				.Feature
523				.Terminals
524				.ActiveTerminals
525				.lock()
526				.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
527
528			TerminalsGuard
529				.get(&TerminalId)
530				.and_then(|TerminalArc| TerminalArc.lock().ok())
531				.and_then(|TerminalStateGuard| TerminalStateGuard.PTYInputTransmitter.clone())
532		};
533
534		if let Some(Sender) = SenderOption {
535			Sender
536				.send(Text)
537				.await
538				.map_err(|Error| CommonError::IPCError { Description:Error.to_string() })
539		} else {
540			Err(CommonError::IPCError {
541				Description:format!("Terminal with ID {} not found or has no input channel.", TerminalId),
542			})
543		}
544	}
545
546	async fn DisposeTerminal(&self, TerminalId:u64) -> Result<(), CommonError> {
547		dev_log!("terminal", "[TerminalProvider] Disposing terminal ID: {}", TerminalId);
548
549		let TerminalArc = self
550			.ApplicationState
551			.Feature
552			.Terminals
553			.ActiveTerminals
554			.lock()
555			.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?
556			.remove(&TerminalId);
557
558		if let Some(TerminalArc) = TerminalArc {
559			// Dropping the PTY master's writer and reader handles will signal the
560			// underlying process to terminate.
561			drop(TerminalArc);
562		}
563
564		Ok(())
565	}
566
567	async fn ShowTerminal(&self, TerminalId:u64, PreserveFocus:bool) -> Result<(), CommonError> {
568		dev_log!("terminal", "[TerminalProvider] Showing terminal ID: {}", TerminalId);
569
570		self.ApplicationHandle
571			.emit(
572				SkyEvent::TerminalShow.AsStr(),
573				json!({ "id": TerminalId, "preserveFocus": PreserveFocus }),
574			)
575			.map_err(|Error| CommonError::UserInterfaceInteraction { Reason:Error.to_string() })
576	}
577
578	async fn HideTerminal(&self, TerminalId:u64) -> Result<(), CommonError> {
579		dev_log!("terminal", "[TerminalProvider] Hiding terminal ID: {}", TerminalId);
580
581		// Low-frequency lifecycle event - safe to route through
582		// `LogSkyEmit` for histogram visibility.
583		LogSkyEmit(
584			&self.ApplicationHandle,
585			SkyEvent::TerminalHide.AsStr(),
586			json!({ "id": TerminalId }),
587		)
588		.map_err(|Error| CommonError::UserInterfaceInteraction { Reason:Error.to_string() })
589	}
590
591	async fn GetTerminalProcessId(&self, TerminalId:u64) -> Result<Option<u32>, CommonError> {
592		let TerminalsGuard = self
593			.ApplicationState
594			.Feature
595			.Terminals
596			.ActiveTerminals
597			.lock()
598			.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
599
600		Ok(TerminalsGuard
601			.get(&TerminalId)
602			.and_then(|t| t.lock().ok().and_then(|g| g.OSProcessIdentifier)))
603	}
604
605	async fn ResizeTerminal(&self, TerminalId:u64, Columns:u16, Rows:u16) -> Result<(), CommonError> {
606		if Columns == 0 || Rows == 0 {
607			return Err(CommonError::InvalidArgument {
608				ArgumentName:"Columns/Rows".to_string(),
609				Reason:format!("Columns and Rows must be ≥ 1 (got {}×{})", Columns, Rows),
610			});
611		}
612
613		// Pull the shared master-PTY handle out of the state lock before touching
614		// it so we never hold the outer terminals map while performing IO.
615		let MasterOption = {
616			let TerminalsGuard = self
617				.ApplicationState
618				.Feature
619				.Terminals
620				.ActiveTerminals
621				.lock()
622				.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
623
624			TerminalsGuard
625				.get(&TerminalId)
626				.and_then(|TerminalArc| TerminalArc.lock().ok())
627				.and_then(|TerminalStateGuard| TerminalStateGuard.PTYMaster.clone())
628		};
629
630		let Master = MasterOption.ok_or_else(|| {
631			CommonError::IPCError {
632				Description:format!("Terminal with ID {} not found or has no PTY master handle.", TerminalId),
633			}
634		})?;
635
636		let Size = PtySize { rows:Rows, cols:Columns, pixel_width:0, pixel_height:0 };
637
638		// Method resolution walks through MutexGuard → Box → dyn MasterPty,
639		// so `Guard.resize(...)` dispatches straight to the trait impl. Keep
640		// the call inside `spawn_blocking` even though portable-pty's resize
641		// is nominally fast - SIGWINCH delivery can stall briefly when the
642		// child shell is ptrace-frozen or mid-syscall.
643		tokio::task::spawn_blocking(move || {
644			let Guard = Master.lock().map_err(|_| "PTY master mutex poisoned".to_string())?;
645			Guard.resize(Size).map_err(|Error| Error.to_string())
646		})
647		.await
648		.map_err(|Error| CommonError::IPCError { Description:format!("resize join error: {}", Error) })?
649		.map_err(|Error| CommonError::IPCError { Description:format!("PTY resize failed: {}", Error) })?;
650
651		dev_log!(
652			"terminal",
653			"[TerminalProvider] Resized terminal ID {} to {}×{}",
654			TerminalId,
655			Columns,
656			Rows
657		);
658
659		Ok(())
660	}
661}