Skip to main content

Mountain/Environment/
TerminalProvider.rs

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