diff --git a/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs index 89b7af3d..71e3fc76 100644 --- a/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs +++ b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs @@ -4,10 +4,14 @@ 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; @@ -19,11 +23,21 @@ namespace ClaudeDo.Ui.Services; /// 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. /// 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; @@ -46,6 +60,7 @@ public sealed class PtyTerminalSession : IDisposable control.ApplyTemplate(); var terminal = control.Terminal ?? throw new InvalidOperationException("TerminalControl template has not been applied yet."); _terminal = terminal; + _control = control; var options = new PtyOptions { @@ -67,6 +82,99 @@ public sealed class PtyTerminalSession : IDisposable _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) @@ -93,11 +201,13 @@ public sealed class PtyTerminalSession : IDisposable catch (IOException) { } } - private void OnTerminalDataReceived(object? sender, TerminalEvents.DataEventArgs e) + 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(e.Data); + var bytes = Encoding.UTF8.GetBytes(data); lock (_writeLock) { try @@ -140,6 +250,11 @@ public sealed class PtyTerminalSession : IDisposable _terminal.DataReceived -= OnTerminalDataReceived; _terminal.Resized -= OnTerminalResized; } + if (_control is not null) + { + _control.RemoveHandler(InputElement.KeyDownEvent, OnControlKeyDown); + _control.RemoveHandler(InputElement.TextInputEvent, OnControlTextInput); + } if (_connection is not null) { _connection.ProcessExited -= OnConnectionProcessExited;