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">