fix(interactive): forward keyboard input to ConPTY terminal

TerminalView's OnKeyDown/OnTextInput early-return when its private pty connection
is null -- which it always is, since we bypass LaunchProcess() to inject a custom
env -- so keystrokes were silently dropped, and Terminal.DataReceived only carries
terminal auto-replies, never user input. Tunnel KeyDown/TextInput on the control,
translate via the terminal's public GenerateKeyInput/GenerateCharInput, and write
to our own IPtyConnection. Focus the control on start (LaunchProcess would have).
This commit is contained in:
mika kuns
2026-07-23 16:47:15 +02:00
parent 3feb08d9d9
commit 25922a2768
+117 -2
View File
@@ -4,10 +4,14 @@ using System.IO;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading; using Avalonia.Threading;
using Iciclecreek.Terminal; using Iciclecreek.Terminal;
using Porta.Pty; using Porta.Pty;
using XTerm.Events; using XTerm.Events;
using XKey = XTerm.Input.Key;
using XModifiers = XTerm.Input.KeyModifiers;
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
@@ -19,11 +23,21 @@ namespace ClaudeDo.Ui.Services;
/// WaitForExit members. Instead we own the <see cref="IPtyConnection"/> end to end: spawn, /// 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 /// 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>. /// 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.
/// </summary> /// </summary>
public sealed class PtyTerminalSession : IDisposable public sealed class PtyTerminalSession : IDisposable
{ {
private IPtyConnection? _connection; private IPtyConnection? _connection;
private XTerm.Terminal? _terminal; private XTerm.Terminal? _terminal;
private TerminalControl? _control;
private CancellationTokenSource? _readCts; private CancellationTokenSource? _readCts;
private readonly object _writeLock = new(); private readonly object _writeLock = new();
private bool _disposed; private bool _disposed;
@@ -46,6 +60,7 @@ public sealed class PtyTerminalSession : IDisposable
control.ApplyTemplate(); control.ApplyTemplate();
var terminal = control.Terminal ?? throw new InvalidOperationException("TerminalControl template has not been applied yet."); var terminal = control.Terminal ?? throw new InvalidOperationException("TerminalControl template has not been applied yet.");
_terminal = terminal; _terminal = terminal;
_control = control;
var options = new PtyOptions var options = new PtyOptions
{ {
@@ -67,6 +82,99 @@ public sealed class PtyTerminalSession : IDisposable
_readCts = new CancellationTokenSource(); _readCts = new CancellationTokenSource();
_ = ReadLoopAsync(_connection, terminal, _readCts.Token); _ = 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) private static async Task ReadLoopAsync(IPtyConnection connection, XTerm.Terminal terminal, CancellationToken ct)
@@ -93,11 +201,13 @@ public sealed class PtyTerminalSession : IDisposable
catch (IOException) { } 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; var connection = _connection;
if (connection is null) return; if (connection is null) return;
var bytes = Encoding.UTF8.GetBytes(e.Data); var bytes = Encoding.UTF8.GetBytes(data);
lock (_writeLock) lock (_writeLock)
{ {
try try
@@ -140,6 +250,11 @@ public sealed class PtyTerminalSession : IDisposable
_terminal.DataReceived -= OnTerminalDataReceived; _terminal.DataReceived -= OnTerminalDataReceived;
_terminal.Resized -= OnTerminalResized; _terminal.Resized -= OnTerminalResized;
} }
if (_control is not null)
{
_control.RemoveHandler(InputElement.KeyDownEvent, OnControlKeyDown);
_control.RemoveHandler(InputElement.TextInputEvent, OnControlTextInput);
}
if (_connection is not null) if (_connection is not null)
{ {
_connection.ProcessExited -= OnConnectionProcessExited; _connection.ProcessExited -= OnConnectionProcessExited;