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.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;
|
||||||
|
|||||||
Reference in New Issue
Block a user