Ctrl+V wurde von TerminalControl als rohes Control-Byte an das Kind weitergegeben und tat nichts; jetzt wird es im Tunnel abgefangen und geht ueber TerminalView.PasteAsync, damit Bracketed Paste greift und ein mehrzeiliger Paste nicht auf der ersten Zeile abschickt. Gedroppte Dateien werden als Pfade in den Composer getippt, ohne Enter.
206 lines
9.5 KiB
C#
206 lines
9.5 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 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;
|
|
}
|
|
}
|