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