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;