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);
+ }
+}