diff --git a/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs index 71e3fc76..e9b73771 100644 --- a/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs +++ b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs @@ -41,6 +41,8 @@ public sealed class PtyTerminalSession : IDisposable 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; } @@ -62,6 +64,19 @@ public sealed class PtyTerminalSession : IDisposable _terminal = terminal; _control = control; + // 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(); + + // 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; + var options = new PtyOptions { Name = "xterm-256color", @@ -75,11 +90,13 @@ public sealed class PtyTerminalSession : IDisposable _connection = await PtyProvider.SpawnAsync(options, ct).ConfigureAwait(false); IsRunning = true; - - terminal.DataReceived += OnTerminalDataReceived; - terminal.Resized += OnTerminalResized; _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); @@ -193,7 +210,16 @@ public sealed class PtyTerminalSession : IDisposable if (charCount <= 0) continue; var text = new string(chars, 0, charCount); - await Dispatcher.UIThread.InvokeAsync(() => terminal.Write(text)); + // 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) { } @@ -220,9 +246,27 @@ public sealed class PtyTerminalSession : IDisposable } } - private void OnTerminalResized(object? sender, TerminalEvents.ResizeEventArgs e) + 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) { - try { _connection?.Resize(e.Cols, e.Rows); } + 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 */ } } @@ -254,6 +298,7 @@ public sealed class PtyTerminalSession : IDisposable { _control.RemoveHandler(InputElement.KeyDownEvent, OnControlKeyDown); _control.RemoveHandler(InputElement.TextInputEvent, OnControlTextInput); + _control.LayoutUpdated -= OnControlLayoutUpdated; } if (_connection is not null) {