Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/InteractiveTerminalViewModel.cs
T
Mika Kuns 1466d0fbab feat(ui): spinners for ConPTY session start and task refine
Both actions previously gave no feedback: opening a ConPTY session only created
the tile after the launch-spec roundtrip (which may build a worktree), and the
refine button just disappeared while the run was in flight.

Add a shared Ellipse.spinner style, and let ConPtyPaneViewModel resolve its own
launch spec so the tile shows up immediately with a starting overlay. A failed
launch now keeps the tile with its inline error banner instead of never
appearing — Start() is separated from the ctor so the host can subscribe to
ErrorReported before the launch begins.
2026-07-27 15:02:51 +02:00

103 lines
3.4 KiB
C#

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;
/// <summary>True from construction until the child process is actually launched (or the launch
/// failed) — covers both the caller's launch-spec roundtrip and the ConPTY spawn, so the host
/// can show a spinner instead of an empty black pane.</summary>
public bool IsStarting => !IsRunning && !HasExited && StartError is null;
partial void OnIsRunningChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting));
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;
}
/// <summary>Reports a failure that happened before <see cref="Start"/> could be called (e.g. the
/// launch-spec roundtrip threw), so it surfaces through the same banner as a spawn failure.</summary>
public void ReportStartFailure(string message)
{
IsRunning = false;
HasExited = true;
StartError = message;
}
public void Kill() => _session.Kill();
public void Dispose()
{
_session.ProcessExited -= OnSessionProcessExited;
_session.Dispose();
}
}