diff --git a/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs
index e9b73771..29b2d920 100644
--- a/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs
+++ b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs
@@ -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;
///
-/// Drives a Porta.Pty child process directly and pumps it through an Iciclecreek
-/// 's XTerm.NET renderer. We deliberately bypass
-/// — 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 end to end: spawn,
-/// pump ReaderStream into Terminal.Write, forward Terminal.DataReceived into
-/// WriterStream, and mirror Terminal.Resized into IPtyConnection.Resize.
-///
-/// Keyboard input is NOT wired through Terminal.DataReceived — 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 — 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
{
- 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? ProcessExited;
///
- /// Spawns the process described by and wires it to
- /// . The control's template must already be applied (host it after
- /// it has loaded) so is available.
+ /// 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 (_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(descriptor.Args);
+ control.StartingDirectory = descriptor.Cwd;
- var options = new PtyOptions
- {
- Name = "xterm-256color",
- App = descriptor.Exe,
- Cwd = descriptor.Cwd,
- CommandLine = new List(descriptor.Args).ToArray(),
- Cols = terminal.Cols,
- Rows = terminal.Rows,
- Environment = new Dictionary(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;
}
}
diff --git a/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml
index 42e23bd8..8e2a5bc4 100644
--- a/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml
+++ b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml
@@ -7,8 +7,9 @@
x:Name="Root">