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:
@@ -16,6 +16,7 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||||
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" />
|
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" />
|
||||||
|
<PackageReference Include="Iciclecreek.Avalonia.Terminal" Version="2.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user