From 5f740c05d87cfe8ea1d1da4dc2074fa8ec59642e Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Jul 2026 14:43:01 +0200 Subject: [PATCH] feat(interactive): embedded ConPTY terminal host in UI Adds a self-contained terminal host: PtyTerminalSession drives a Porta.Pty child directly (custom env) and pumps it through Iciclecreek's XTerm.NET renderer via TerminalControl.Terminal, bypassing LaunchProcess() so a fully-populated environment can be passed. InteractiveTerminalView/ViewModel host it with an order-independent attach/start. Adds Iciclecreek.Avalonia.Terminal 2.0.3. Sets Process="" to suppress the control's auto-launch of a stray shell. --- src/ClaudeDo.Ui/ClaudeDo.Ui.csproj | 1 + .../Services/PtyTerminalSession.cs | 152 ++++++++++++++++++ .../Services/TerminalLaunchDescriptor.cs | 10 ++ .../InteractiveTerminalViewModel.cs | 84 ++++++++++ .../Views/InteractiveTerminalView.axaml | 14 ++ .../Views/InteractiveTerminalView.axaml.cs | 28 ++++ 6 files changed, 289 insertions(+) create mode 100644 src/ClaudeDo.Ui/Services/PtyTerminalSession.cs create mode 100644 src/ClaudeDo.Ui/Services/TerminalLaunchDescriptor.cs create mode 100644 src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs create mode 100644 src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml create mode 100644 src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj index 1bc9dacb..57689421 100644 --- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj @@ -16,6 +16,7 @@ + diff --git a/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs new file mode 100644 index 00000000..89b7af3d --- /dev/null +++ b/src/ClaudeDo.Ui/Services/PtyTerminalSession.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using Iciclecreek.Terminal; +using Porta.Pty; +using XTerm.Events; + +namespace ClaudeDo.Ui.Services; + +/// +/// Drives a Porta.Pty child process directly and pumps it through an Iciclecreek +/// 's XTerm.NET renderer. We deliberately bypass +/// — it can't take a custom environment and +/// leaves the control's internal pty connection null, which breaks its ExitCode/Pid/Kill/ +/// 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. +/// +public sealed class PtyTerminalSession : IDisposable +{ + private IPtyConnection? _connection; + private XTerm.Terminal? _terminal; + private CancellationTokenSource? _readCts; + private readonly object _writeLock = new(); + private bool _disposed; + + public bool IsRunning { get; private set; } + public int? ExitCode { get; private set; } + + /// Raised when the child process exits, on the UI thread, with its exit code. + public event EventHandler? ProcessExited; + + /// + /// Spawns the process described by and wires it to + /// . The control's template must already be applied (host it after + /// it has loaded) so is available. + /// + public async Task StartAsync(TerminalLaunchDescriptor descriptor, TerminalControl control, CancellationToken ct = default) + { + if (_connection is not null) throw new InvalidOperationException("Session already started."); + + control.ApplyTemplate(); + var terminal = control.Terminal ?? throw new InvalidOperationException("TerminalControl template has not been applied yet."); + _terminal = terminal; + + var options = new PtyOptions + { + Name = "xterm-256color", + App = descriptor.Exe, + Cwd = descriptor.Cwd, + CommandLine = new List(descriptor.Args).ToArray(), + Cols = terminal.Cols, + Rows = terminal.Rows, + Environment = new Dictionary(descriptor.Env), + }; + + _connection = await PtyProvider.SpawnAsync(options, ct).ConfigureAwait(false); + IsRunning = true; + + terminal.DataReceived += OnTerminalDataReceived; + terminal.Resized += OnTerminalResized; + _connection.ProcessExited += OnConnectionProcessExited; + + _readCts = new CancellationTokenSource(); + _ = ReadLoopAsync(_connection, terminal, _readCts.Token); + } + + private static async Task ReadLoopAsync(IPtyConnection connection, XTerm.Terminal terminal, CancellationToken ct) + { + var buffer = new byte[4096]; + var chars = new char[4096]; + var decoder = Encoding.UTF8.GetDecoder(); + try + { + while (!ct.IsCancellationRequested) + { + var read = await connection.ReaderStream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false); + if (read <= 0) break; + + var charCount = decoder.GetChars(buffer, 0, read, chars, 0, false); + if (charCount <= 0) continue; + var text = new string(chars, 0, charCount); + + await Dispatcher.UIThread.InvokeAsync(() => terminal.Write(text)); + } + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch (IOException) { } + } + + private void OnTerminalDataReceived(object? sender, TerminalEvents.DataEventArgs e) + { + var connection = _connection; + if (connection is null) return; + var bytes = Encoding.UTF8.GetBytes(e.Data); + lock (_writeLock) + { + try + { + connection.WriterStream.Write(bytes, 0, bytes.Length); + connection.WriterStream.Flush(); + } + catch (IOException) { } + catch (ObjectDisposedException) { } + } + } + + private void OnTerminalResized(object? sender, TerminalEvents.ResizeEventArgs e) + { + try { _connection?.Resize(e.Cols, e.Rows); } + catch (Exception) { /* pty may already have exited */ } + } + + private void OnConnectionProcessExited(object? sender, PtyExitedEventArgs e) + { + IsRunning = false; + ExitCode = e.ExitCode; + _readCts?.Cancel(); + Dispatcher.UIThread.Post(() => ProcessExited?.Invoke(this, e.ExitCode)); + } + + public void Kill() + { + try { _connection?.Kill(); } + catch (Exception) { /* already exited */ } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + if (_terminal is not null) + { + _terminal.DataReceived -= OnTerminalDataReceived; + _terminal.Resized -= OnTerminalResized; + } + if (_connection is not null) + { + _connection.ProcessExited -= OnConnectionProcessExited; + try { _connection.Kill(); } catch { /* already exited */ } + _connection.Dispose(); + } + _readCts?.Cancel(); + _readCts?.Dispose(); + } +} diff --git a/src/ClaudeDo.Ui/Services/TerminalLaunchDescriptor.cs b/src/ClaudeDo.Ui/Services/TerminalLaunchDescriptor.cs new file mode 100644 index 00000000..16673ef8 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/TerminalLaunchDescriptor.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace ClaudeDo.Ui.Services; + +/// Plain launch spec for an embedded ConPTY terminal session — no worker/SignalR coupling. +public sealed record TerminalLaunchDescriptor( + string Cwd, + string Exe, + IReadOnlyList Args, + IReadOnlyDictionary Env); diff --git a/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs new file mode 100644 index 00000000..6b335800 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using ClaudeDo.Ui.Services; +using Iciclecreek.Terminal; + +namespace ClaudeDo.Ui.ViewModels; + +/// +/// Hosts a for an embedded ConPTY terminal. The view attaches +/// its once loaded via ; +/// can be called before or after attach — whichever happens second triggers the launch. +/// +public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDisposable +{ + private readonly PtyTerminalSession _session = new(); + private TerminalControl? _control; + private TerminalLaunchDescriptor? _pendingDescriptor; + + [ObservableProperty] private bool _isRunning; + [ObservableProperty] private bool _hasExited; + [ObservableProperty] private int? _exitCode; + [ObservableProperty] private string? _startError; + + public InteractiveTerminalViewModel() + { + _session.ProcessExited += OnSessionProcessExited; + } + + /// Called by the view once its has loaded (template applied). + public void AttachControl(TerminalControl control) + { + _control = control; + if (_pendingDescriptor is { } descriptor) + { + _pendingDescriptor = null; + _ = StartCoreAsync(descriptor); + } + } + + public void Start(TerminalLaunchDescriptor descriptor) + { + if (_control is null) + { + _pendingDescriptor = descriptor; + return; + } + _ = StartCoreAsync(descriptor); + } + + private async Task StartCoreAsync(TerminalLaunchDescriptor descriptor) + { + if (_control is null) return; + try + { + StartError = null; + await _session.StartAsync(descriptor, _control); + IsRunning = true; + HasExited = false; + ExitCode = null; + } + catch (Exception ex) + { + IsRunning = false; + HasExited = true; + StartError = ex.Message; + } + } + + private void OnSessionProcessExited(object? sender, int exitCode) + { + IsRunning = false; + HasExited = true; + ExitCode = exitCode; + } + + public void Kill() => _session.Kill(); + + public void Dispose() + { + _session.ProcessExited -= OnSessionProcessExited; + _session.Dispose(); + } +} diff --git a/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml new file mode 100644 index 00000000..42e23bd8 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml @@ -0,0 +1,14 @@ + + + + diff --git a/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs new file mode 100644 index 00000000..2eaee755 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs @@ -0,0 +1,28 @@ +using System; +using Avalonia.Controls; +using Avalonia.Interactivity; +using ClaudeDo.Ui.ViewModels; + +namespace ClaudeDo.Ui.Views; + +public partial class InteractiveTerminalView : UserControl +{ + public InteractiveTerminalView() + { + InitializeComponent(); + TerminalHost.Loaded += OnTerminalHostLoaded; + DataContextChanged += OnDataContextChanged; + } + + private void OnDataContextChanged(object? sender, EventArgs e) + { + if (DataContext is InteractiveTerminalViewModel vm && TerminalHost.IsLoaded) + vm.AttachControl(TerminalHost); + } + + private void OnTerminalHostLoaded(object? sender, RoutedEventArgs e) + { + if (DataContext is InteractiveTerminalViewModel vm) + vm.AttachControl(TerminalHost); + } +}