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
@@ -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();
}
}