using System; using System.Collections.Generic; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Iciclecreek.Terminal; namespace ClaudeDo.Ui.Services; /// /// Thin wrapper around — the library owns the /// Porta.Pty spawn, keyboard input, rendering, resize, and focus end to end (see /// spikes/ConPtyTerminal/MainWindow.axaml.cs, which proved this renders correctly and /// stays responsive). We only: /// 1) apply onto the current process environment /// before launching — Porta.Pty inherits the calling process's environment and there is no /// per-launch env seam on / — /// 2) relay the control's own event and /// method. /// public sealed class PtyTerminalSession : IDisposable { // Guards the set-env + LaunchProcess critical section below: two sessions starting // back-to-back (e.g. planning sessions for two different tasks) could otherwise interleave // their SetEnvironmentVariable calls before either LaunchProcess() forks, so one process // inherits the other's env (e.g. CLAUDEDO_PLANNING_TOKEN, breaking that session's own MCP // auth). Process-wide env leakage AFTER a launch has forked remains a documented limitation // — Porta.Pty has no per-launch env seam, so the vars stay set on the whole UI process. // The wait is bounded (see WaitForLaunchGateAsync) — a hung launch (slow disk, AV scanning // claude.exe, a Porta.Pty/ConPTY hiccup) must not freeze every other pane open behind it. private static readonly SemaphoreSlim s_launchGate = new(1, 1); private static readonly TimeSpan s_launchGateTimeout = TimeSpan.FromSeconds(30); /// /// Waits on for at most , throwing /// instead of blocking forever. Never acquires the gate on /// timeout, so callers must not release it in that case. /// internal static async Task WaitForLaunchGateAsync(SemaphoreSlim gate, TimeSpan timeout, CancellationToken ct) { if (!await gate.WaitAsync(timeout, ct)) throw new TimeoutException("Another terminal launch is still starting up. Please retry in a moment."); } private TerminalControl? _control; private bool _disposed; public bool IsRunning { get; private set; } public int? ExitCode { get; private set; } /// Raised when the child process exits, on the UI thread, with its exit code. public event EventHandler? ProcessExited; /// /// Applies 's env vars to the current process, then drives /// to launch it via . /// public async Task StartAsync(TerminalLaunchDescriptor descriptor, TerminalControl control, CancellationToken ct = default) { if (_control is not null) throw new InvalidOperationException("Session already started."); _control = control; control.ProcessExited += OnControlProcessExited; control.Process = descriptor.Exe; control.Args = new List(descriptor.Args); control.StartingDirectory = descriptor.Cwd; await WaitForLaunchGateAsync(s_launchGate, s_launchGateTimeout, ct); try { // If ClaudeDo.App itself was started from inside a claude session (e.g. a dev/test // launch), it inherits these markers and every embedded PTY session would otherwise // inherit them too — the child claude then thinks it's a spawned subagent and // disables transcript saving / degrades its own startup banner. Every ConPTY tile // must look like a fresh top-level session regardless of how ClaudeDo.App was launched. Environment.SetEnvironmentVariable("CLAUDE_CODE_CHILD_SESSION", null); Environment.SetEnvironmentVariable("CLAUDECODE", null); foreach (var (key, value) in descriptor.Env) Environment.SetEnvironmentVariable(key, value); await control.LaunchProcess(); } finally { s_launchGate.Release(); } // Permanent reparent mode: TerminalView.OnDetachedFromLogicalTree kills the child // process unless BeginReparent() suppressed it, and Mission Control detaches pane // views routinely (overview-grid rebuilds on pane add/remove, focus-mode tab // switches). ClaudeDo owns teardown explicitly instead — ConPtyPaneViewModel.Dispose // calls Kill() when a pane closes — so EndReparent is deliberately never called. control.BeginReparent(); IsRunning = true; } private void OnControlProcessExited(object? sender, ProcessExitedEventArgs e) { IsRunning = false; ExitCode = e.ExitCode; ProcessExited?.Invoke(this, e.ExitCode); } /// /// Types into the running session and submits it, exactly as if the user /// had typed it — used to warn a list-handler session before the usage limit /// (HandlerUsageWarning). Verified against a real `claude` TUI, including while it was /// mid-turn (the message is queued and acted on when the turn ends). /// /// Three non-obvious details, all established by that spike: /// • exposes no public write path at all — its only public paste is /// TerminalView.PasteAsync(), which takes no argument and reads the clipboard. The /// private SendToPtyAsync on the inner view is the write seam, hence the reflection. /// • The text and the Enter must be TWO writes. A single text + "\r" chunk lands in /// claude's composer but is never submitted — a chunk carrying an embedded CR reads as pasted /// text, not as a keypress. /// • The Enter bytes come from the emulator (GenerateKeyInput) rather than a literal /// "\r", because claude turns Win32 input mode on and the emulator owns that encoding. /// /// Best-effort by design: a package upgrade that renames the private member costs a warning that /// never arrives, never a crashed Mission Control. /// public async Task SendLineAsync(string text) { if (_control is not { } control || !IsRunning || string.IsNullOrEmpty(text)) return false; try { await SendRawAsync(control, text); await Task.Delay(SubmitDelay); await SendRawAsync(control, control.Terminal.GenerateKeyInput(XTerm.Input.Key.Enter, XTerm.Input.KeyModifiers.None)); return true; } catch (Exception) { return false; } } /// /// Inserts at the cursor without submitting it — the drop-a-file-path /// case. Same write seam as , just without the Enter. /// public async Task SendTextAsync(string text) { if (_control is not { } control || !IsRunning || string.IsNullOrEmpty(text)) return false; try { await SendRawAsync(control, text); return true; } catch (Exception) { return false; } } /// /// Pastes the clipboard into the session. wires paste to the /// right-click menu only, so Ctrl+V otherwise reaches the child as a raw control byte and does /// nothing. Goes through the emulator's own rather than /// writing the clipboard text raw, because that is what honours bracketed-paste mode — without /// it a multi-line paste is read line by line and submits on the first newline. /// public async Task PasteAsync() { if (_control is not { } control || !IsRunning) return; try { if (s_terminalViewField?.GetValue(control) is TerminalView view) await view.PasteAsync(); } catch (Exception) { /* best-effort, same as SendLineAsync */ } } // Long enough that the two writes can't be read as one chunk by the child's stdin reader. private static readonly TimeSpan SubmitDelay = TimeSpan.FromMilliseconds(150); private static readonly FieldInfo? s_terminalViewField = typeof(TerminalControl).GetField("_terminalView", BindingFlags.Instance | BindingFlags.NonPublic); private static readonly MethodInfo? s_sendToPty = typeof(TerminalView).GetMethod("SendToPtyAsync", BindingFlags.Instance | BindingFlags.NonPublic); private static Task SendRawAsync(TerminalControl control, string data) { if (s_terminalViewField?.GetValue(control) is not TerminalView view || s_sendToPty is null) throw new InvalidOperationException("Iciclecreek.Avalonia.Terminal no longer exposes a PTY write seam."); return (Task)s_sendToPty.Invoke(view, new object[] { data, CancellationToken.None })!; } public void Kill() { try { _control?.Kill(); } catch (Exception) { /* already exited */ } } public void Dispose() { if (_disposed) return; _disposed = true; if (_control is not null) _control.ProcessExited -= OnControlProcessExited; } }