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.
This commit is contained in:
mika kuns
2026-07-23 16:47:15 +02:00
parent 1245e75902
commit 5f740c05d8
6 changed files with 289 additions and 0 deletions
+1
View File
@@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" />
<PackageReference Include="Iciclecreek.Avalonia.Terminal" Version="2.0.3" />
</ItemGroup>
<ItemGroup>
@@ -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;
/// <summary>
/// Drives a Porta.Pty child process directly and pumps it through an Iciclecreek
/// <see cref="TerminalControl"/>'s XTerm.NET renderer. We deliberately bypass
/// <see cref="TerminalControl.LaunchProcess()"/> — 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 <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>.
/// </summary>
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; }
/// <summary>Raised when the child process exits, on the UI thread, with its exit code.</summary>
public event EventHandler<int>? ProcessExited;
/// <summary>
/// Spawns the process described by <paramref name="descriptor"/> and wires it to
/// <paramref name="control"/>. The control's template must already be applied (host it after
/// it has loaded) so <see cref="TerminalControl.Terminal"/> is available.
/// </summary>
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<string>(descriptor.Args).ToArray(),
Cols = terminal.Cols,
Rows = terminal.Rows,
Environment = new Dictionary<string, string>(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();
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ClaudeDo.Ui.Services;
/// <summary>Plain launch spec for an embedded ConPTY terminal session — no worker/SignalR coupling.</summary>
public sealed record TerminalLaunchDescriptor(
string Cwd,
string Exe,
IReadOnlyList<string> Args,
IReadOnlyDictionary<string, string> Env);
@@ -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;
/// <summary>
/// Hosts a <see cref="PtyTerminalSession"/> for an embedded ConPTY terminal. The view attaches
/// its <see cref="TerminalControl"/> once loaded via <see cref="AttachControl"/>; <see cref="Start"/>
/// can be called before or after attach — whichever happens second triggers the launch.
/// </summary>
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;
}
/// <summary>Called by the view once its <see cref="TerminalControl"/> has loaded (template applied).</summary>
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();
}
}
@@ -0,0 +1,14 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:term="using:Iciclecreek.Terminal"
x:Class="ClaudeDo.Ui.Views.InteractiveTerminalView"
x:DataType="vm:InteractiveTerminalViewModel"
x:Name="Root">
<!--
Process="" suppresses TerminalView.OnLoaded's built-in auto-launch (it otherwise spawns its
own cmd.exe/bash the moment this control loads). We drive the pty ourselves via
PtyTerminalSession/InteractiveTerminalViewModel instead.
-->
<term:TerminalControl x:Name="TerminalHost" Process="" />
</UserControl>
@@ -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);
}
}