Files
ClaudeDo/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs
T
mika kuns 0cec0a77ee fix(ui): report failed paste/drop in the interactive terminal
PasteAsync silently swallowed every exception and returned void, so a
package update renaming Iciclecreek's private write seam would break
Ctrl+V/drop with zero user-visible signal. PasteAsync/SendTextAsync now
return bool (mirroring SendLineAsync), and the view checks the result,
routing a failure through InteractiveTerminalViewModel.ErrorReported ->
ConPtyPaneViewModel.ErrorReported -> the existing footer error strip.
2026-09-10 13:35:16 +02:00

210 lines
9.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Iciclecreek.Terminal;
namespace ClaudeDo.Ui.Services;
/// <summary>
/// Thin wrapper around <see cref="TerminalControl.LaunchProcess()"/> — the library owns the
/// Porta.Pty spawn, keyboard input, rendering, resize, and focus end to end (see
/// <c>spikes/ConPtyTerminal/MainWindow.axaml.cs</c>, which proved this renders correctly and
/// stays responsive). We only:
/// 1) apply <see cref="TerminalLaunchDescriptor.Env"/> onto the current process environment
/// before launching — Porta.Pty inherits the calling process's environment and there is no
/// per-launch env seam on <see cref="TerminalControl"/>/<see cref="TerminalControl.LaunchProcess()"/> —
/// 2) relay the control's own <see cref="TerminalControl.ProcessExited"/> event and
/// <see cref="TerminalControl.Kill()"/> method.
/// </summary>
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);
/// <summary>
/// Waits on <paramref name="gate"/> for at most <paramref name="timeout"/>, throwing
/// <see cref="TimeoutException"/> instead of blocking forever. Never acquires the gate on
/// timeout, so callers must not release it in that case.
/// </summary>
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; }
/// <summary>Raised when the child process exits, on the UI thread, with its exit code.</summary>
public event EventHandler<int>? ProcessExited;
/// <summary>
/// Applies <paramref name="descriptor"/>'s env vars to the current process, then drives
/// <paramref name="control"/> to launch it via <see cref="TerminalControl.LaunchProcess()"/>.
/// </summary>
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<string>(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);
}
/// <summary>
/// Types <paramref name="text"/> 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
/// (<c>HandlerUsageWarning</c>). 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:
/// • <see cref="TerminalControl"/> exposes no public write path at all — its only public paste is
/// <c>TerminalView.PasteAsync()</c>, which takes no argument and reads the clipboard. The
/// private <c>SendToPtyAsync</c> on the inner view is the write seam, hence the reflection.
/// • The text and the Enter must be TWO writes. A single <c>text + "\r"</c> 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 (<c>GenerateKeyInput</c>) 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.
/// </summary>
public async Task<bool> 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;
}
}
/// <summary>
/// Inserts <paramref name="text"/> at the cursor without submitting it — the drop-a-file-path
/// case. Same write seam as <see cref="SendLineAsync"/>, just without the Enter.
/// </summary>
public async Task<bool> 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;
}
}
/// <summary>
/// Pastes the clipboard into the session. <see cref="TerminalControl"/> 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 <see cref="TerminalView.PasteAsync"/> 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.
/// </summary>
public async Task<bool> PasteAsync()
{
if (_control is not { } control || !IsRunning) return false;
try
{
if (s_terminalViewField?.GetValue(control) is not TerminalView view) return false;
await view.PasteAsync();
return true;
}
catch (Exception)
{
return false;
}
}
// 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;
}
}