fix(interactive): use library LaunchProcess instead of custom pty bypass

The custom Porta.Pty bypass (own read loop, key tunneling, resize sync) rendered
wrong, lagged, and dropped input. The spike proved TerminalControl.LaunchProcess()
renders correctly and stays responsive, so hand pty/input/render/resize/focus back
to the library. PtyTerminalSession shrinks to a thin wrapper: apply descriptor.Env
process-wide (Porta.Pty inherits the process env; no per-launch env seam), set
Process/Args/StartingDirectory, LaunchProcess(). Process="" still suppresses the
control's auto-launch so exactly one process starts.
This commit is contained in:
mika kuns
2026-07-23 16:47:15 +02:00
parent bb62740ac8
commit 2b06ab0ab4
2 changed files with 26 additions and 264 deletions
+23 -262
View File
@@ -1,48 +1,26 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using Iciclecreek.Terminal;
using Porta.Pty;
using XTerm.Events;
using XKey = XTerm.Input.Key;
using XModifiers = XTerm.Input.KeyModifiers;
namespace ClaudeDo.Ui.Services;
/// <summary>
/// Drives a Porta.Pty child process directly and pumps it through an Iciclecreek
/// <see cref="TerminalControl"/>'s XTerm.NET renderer. We deliberately bypass
/// <see cref="TerminalControl.LaunchProcess()"/> — it can't take a custom environment and
/// leaves the control's internal pty connection null, which breaks its ExitCode/Pid/Kill/
/// WaitForExit members. Instead we own the <see cref="IPtyConnection"/> end to end: spawn,
/// pump ReaderStream into <c>Terminal.Write</c>, forward <c>Terminal.DataReceived</c> into
/// WriterStream, and mirror <c>Terminal.Resized</c> into <c>IPtyConnection.Resize</c>.
///
/// Keyboard input is NOT wired through <c>Terminal.DataReceived</c> — that event only fires
/// for terminal-initiated auto-replies (device attributes, cursor position reports, OSC 52,
/// title queries), never for user keystrokes. And the control's own OnKeyDown/OnKeyUp/
/// OnTextInput short-circuit whenever the control's own (private, TerminalView-internal)
/// pty connection is null — which it always is here, since we never call LaunchProcess().
/// So we tunnel KeyDown/TextInput on the control ourselves and translate them via the
/// terminal's own public GenerateKeyInput/GenerateCharInput, writing straight to our
/// connection's WriterStream.
/// 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
{
private IPtyConnection? _connection;
private XTerm.Terminal? _terminal;
private TerminalControl? _control;
private CancellationTokenSource? _readCts;
private readonly object _writeLock = new();
private bool _disposed;
private int _lastSyncedCols;
private int _lastSyncedRows;
public bool IsRunning { get; private set; }
public int? ExitCode { get; private set; }
@@ -51,236 +29,36 @@ public sealed class PtyTerminalSession : IDisposable
public event EventHandler<int>? ProcessExited;
/// <summary>
/// Spawns the process described by <paramref name="descriptor"/> and wires it to
/// <paramref name="control"/>. The control's template must already be applied (host it after
/// it has loaded) so <see cref="TerminalControl.Terminal"/> is available.
/// 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 (_connection is not null) throw new InvalidOperationException("Session already started.");
control.ApplyTemplate();
var terminal = control.Terminal ?? throw new InvalidOperationException("TerminalControl template has not been applied yet.");
_terminal = terminal;
if (_control is not null) throw new InvalidOperationException("Session already started.");
_control = control;
control.ProcessExited += OnControlProcessExited;
// ApplyTemplate() only builds the control's visual tree — it does not run a layout
// pass, so terminal.Cols/Rows would still be the TerminalOptions defaults (80x24) here,
// not the pane's real size. Force a synchronous layout pass so TerminalView.ArrangeOverride
// runs and sets terminal.Cols/Rows from the control's actual Bounds before we read them
// for the child process's initial size.
control.UpdateLayout();
foreach (var (key, value) in descriptor.Env)
Environment.SetEnvironmentVariable(key, value);
// Subscribe before spawning (not after) so a resize that lands while SpawnAsync is still
// being awaited (e.g. the surrounding layout settling further) isn't missed.
terminal.DataReceived += OnTerminalDataReceived;
terminal.Resized += OnTerminalResized;
control.LayoutUpdated += OnControlLayoutUpdated;
control.Process = descriptor.Exe;
control.Args = new List<string>(descriptor.Args);
control.StartingDirectory = descriptor.Cwd;
var options = new PtyOptions
{
Name = "xterm-256color",
App = descriptor.Exe,
Cwd = descriptor.Cwd,
CommandLine = new List<string>(descriptor.Args).ToArray(),
Cols = terminal.Cols,
Rows = terminal.Rows,
Environment = new Dictionary<string, string>(descriptor.Env),
};
_connection = await PtyProvider.SpawnAsync(options, ct).ConfigureAwait(false);
await control.LaunchProcess();
IsRunning = true;
_connection.ProcessExited += OnConnectionProcessExited;
// The control may have been (re)arranged while the spawn above was in flight; resync once
// now so the pty always starts at the terminal's current, real size rather than whatever
// was captured in PtyOptions.
SyncPtySize(terminal.Cols, terminal.Rows);
_readCts = new CancellationTokenSource();
_ = ReadLoopAsync(_connection, terminal, _readCts.Token);
// The control's own key/text handlers are gated on its private (always-null, here)
// pty connection, so they never fire. Tunnel so we see the event before the control's
// bubble-routed (no-op) handlers do, and translate ourselves via the terminal's public
// sequence generators.
control.AddHandler(InputElement.KeyDownEvent, OnControlKeyDown, RoutingStrategies.Tunnel);
control.AddHandler(InputElement.TextInputEvent, OnControlTextInput, RoutingStrategies.Tunnel);
// Nothing else focuses the control (TerminalControl.LaunchProcess() would have, but we
// never call it) — without this the terminal never receives keyboard input until the
// user happens to click into it.
Dispatcher.UIThread.Post(() => control.Focus());
}
private void OnControlKeyDown(object? sender, KeyEventArgs e)
{
var terminal = _terminal;
if (terminal is null || _connection is null) return;
var modifiers = ToXTermModifiers(e.KeyModifiers);
string? sequence = null;
if (ToXTermKey(e.Key) is { } xKey)
{
sequence = terminal.GenerateKeyInput(xKey, modifiers);
}
else if ((e.KeyModifiers & (KeyModifiers.Control | KeyModifiers.Alt)) != 0 && ToLetterOrDigit(e.Key) is { } ch)
{
// Ctrl/Alt+letter (Ctrl-C, Ctrl-D, Ctrl-U, Alt-B, ...) never reaches TextInput, so
// it has to be encoded here from the key itself.
sequence = terminal.GenerateCharInput(ch, modifiers);
}
if (!string.IsNullOrEmpty(sequence))
{
WriteToPty(sequence);
e.Handled = true;
}
}
private void OnControlTextInput(object? sender, TextInputEventArgs e)
{
if (_connection is null || string.IsNullOrEmpty(e.Text)) return;
WriteToPty(e.Text);
e.Handled = true;
}
private static XKey? ToXTermKey(Key key) => key switch
{
Key.Enter => XKey.Enter,
Key.Tab => XKey.Tab,
Key.Back => XKey.Backspace,
Key.Escape => XKey.Escape,
Key.Space => XKey.Space,
Key.Up => XKey.UpArrow,
Key.Down => XKey.DownArrow,
Key.Right => XKey.RightArrow,
Key.Left => XKey.LeftArrow,
Key.Home => XKey.Home,
Key.End => XKey.End,
Key.PageUp => XKey.PageUp,
Key.PageDown => XKey.PageDown,
Key.Insert => XKey.Insert,
Key.Delete => XKey.Delete,
Key.F1 => XKey.F1,
Key.F2 => XKey.F2,
Key.F3 => XKey.F3,
Key.F4 => XKey.F4,
Key.F5 => XKey.F5,
Key.F6 => XKey.F6,
Key.F7 => XKey.F7,
Key.F8 => XKey.F8,
Key.F9 => XKey.F9,
Key.F10 => XKey.F10,
Key.F11 => XKey.F11,
Key.F12 => XKey.F12,
_ => null,
};
private static char? ToLetterOrDigit(Key key)
{
if (key >= Key.A && key <= Key.Z) return (char)('a' + (key - Key.A));
if (key >= Key.D0 && key <= Key.D9) return (char)('0' + (key - Key.D0));
return null;
}
private static XModifiers ToXTermModifiers(KeyModifiers modifiers)
{
var result = XModifiers.None;
if ((modifiers & KeyModifiers.Shift) != 0) result |= XModifiers.Shift;
if ((modifiers & KeyModifiers.Alt) != 0) result |= XModifiers.Alt;
if ((modifiers & KeyModifiers.Control) != 0) result |= XModifiers.Control;
return result;
}
private static async Task ReadLoopAsync(IPtyConnection connection, XTerm.Terminal terminal, CancellationToken ct)
{
var buffer = new byte[4096];
var chars = new char[4096];
var decoder = Encoding.UTF8.GetDecoder();
try
{
while (!ct.IsCancellationRequested)
{
var read = await connection.ReaderStream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false);
if (read <= 0) break;
var charCount = decoder.GetChars(buffer, 0, read, chars, 0, false);
if (charCount <= 0) continue;
var text = new string(chars, 0, charCount);
// Post (not InvokeAsync) so this loop doesn't block on a UI-thread round trip
// per chunk — awaiting each dispatch here serialized pipe reads behind UI-thread
// catch-up and was the dominant source of input/output lag. Dispatcher.Post
// preserves FIFO order, so chunks still land in the order they were read; no
// reordering or loss, just no backpressure from the UI thread onto the pipe read.
Dispatcher.UIThread.Post(() =>
{
try { terminal.Write(text); }
catch (Exception) { /* terminal may have been torn down mid-flight */ }
});
}
}
catch (OperationCanceledException) { }
catch (ObjectDisposedException) { }
catch (IOException) { }
}
private void OnTerminalDataReceived(object? sender, TerminalEvents.DataEventArgs e) => WriteToPty(e.Data);
private void WriteToPty(string data)
{
var connection = _connection;
if (connection is null) return;
var bytes = Encoding.UTF8.GetBytes(data);
lock (_writeLock)
{
try
{
connection.WriterStream.Write(bytes, 0, bytes.Length);
connection.WriterStream.Flush();
}
catch (IOException) { }
catch (ObjectDisposedException) { }
}
}
private void OnTerminalResized(object? sender, TerminalEvents.ResizeEventArgs e) => SyncPtySize(e.Cols, e.Rows);
// Layoutable.LayoutUpdated fires after every layout pass in the window (not just when this
// control's own bounds change), so it's a reliable, level-triggered safety net: whatever
// TerminalView.ArrangeOverride last computed for terminal.Cols/Rows, make sure the pty agrees.
// This covers races around the initial spawn where a Resized event could otherwise be missed.
private void OnControlLayoutUpdated(object? sender, EventArgs e)
{
var terminal = _terminal;
if (terminal is null) return;
SyncPtySize(terminal.Cols, terminal.Rows);
}
private void SyncPtySize(int cols, int rows)
{
var connection = _connection;
if (connection is null) return;
if (cols == _lastSyncedCols && rows == _lastSyncedRows) return;
_lastSyncedCols = cols;
_lastSyncedRows = rows;
try { connection.Resize(cols, rows); }
catch (Exception) { /* pty may already have exited */ }
}
private void OnConnectionProcessExited(object? sender, PtyExitedEventArgs e)
private void OnControlProcessExited(object? sender, ProcessExitedEventArgs e)
{
IsRunning = false;
ExitCode = e.ExitCode;
_readCts?.Cancel();
Dispatcher.UIThread.Post(() => ProcessExited?.Invoke(this, e.ExitCode));
ProcessExited?.Invoke(this, e.ExitCode);
}
public void Kill()
{
try { _connection?.Kill(); }
try { _control?.Kill(); }
catch (Exception) { /* already exited */ }
}
@@ -289,24 +67,7 @@ public sealed class PtyTerminalSession : IDisposable
if (_disposed) return;
_disposed = true;
if (_terminal is not null)
{
_terminal.DataReceived -= OnTerminalDataReceived;
_terminal.Resized -= OnTerminalResized;
}
if (_control is not null)
{
_control.RemoveHandler(InputElement.KeyDownEvent, OnControlKeyDown);
_control.RemoveHandler(InputElement.TextInputEvent, OnControlTextInput);
_control.LayoutUpdated -= OnControlLayoutUpdated;
}
if (_connection is not null)
{
_connection.ProcessExited -= OnConnectionProcessExited;
try { _connection.Kill(); } catch { /* already exited */ }
_connection.Dispose();
}
_readCts?.Cancel();
_readCts?.Dispose();
_control.ProcessExited -= OnControlProcessExited;
}
}
@@ -7,8 +7,9 @@
x:Name="Root">
<!--
Process="" suppresses TerminalView.OnLoaded's built-in auto-launch (it otherwise spawns its
own cmd.exe/bash the moment this control loads). We drive the pty ourselves via
PtyTerminalSession/InteractiveTerminalViewModel instead.
own cmd.exe/bash the moment this control loads). PtyTerminalSession sets Process/Args/
StartingDirectory to our real target and calls TerminalControl.LaunchProcess() exactly once,
once the view model's descriptor is known (see AttachControl/Start).
-->
<term:TerminalControl x:Name="TerminalHost" Process="" />
</UserControl>