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:
@@ -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 <see cref="IPtyConnection"/> end to end: spawn,
|
||||
/// 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>.
|
||||
///
|
||||
/// 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>
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user