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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user