using System; using System.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.ViewModels.MissionControl; /// /// Command Center pane hosting an embedded ConPTY terminal for either one task's interactive /// Claude session, or an ad-hoc/free session in a user-chosen directory (no task, /// is null — ad-hoc panes are never deduped, unlike task-based ones). /// public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable { public string? TaskId { get; } // Only a task-based pane can be submitted for review (an ad-hoc directory session has no task). public bool IsTaskBased => TaskId is not null; [ObservableProperty] private string _displayTitle; [ObservableProperty] private InteractiveTerminalViewModel _terminal = new(); /// Set by the host (Mission Control) while a submit-for-review round trip is in /// flight, so a rapid double-click can't race two submissions for the same task. [ObservableProperty] private bool _isSubmitPending; /// Raised when the terminal failed to start — the host surfaces this via the footer error strip. public event Action? ErrorReported; /// Set by the host (Mission Control) to remove this pane from its collection. public Action? CloseRequested { get; set; } /// Raised when the user submits this task's hand-driven work for review; the host /// commits the worktree and moves the task to WaitingForReview. public event Action? SubmitForReviewRequested; /// Raised with (taskId, exitCode) when a task-based session that had been running /// dies non-zero. The host reports it to the worker, which decides whether this was a list /// handler killed by the usage limit and worth reopening. Ad-hoc panes never raise it — there is /// no task to reopen. public event Action? SessionDiedAfterStartup; /// Task-based pane — dedup'd by . Pass null for an ad-hoc pane /// (no task, never deduped); prefer at ad-hoc call sites. /// is resolved by the pane itself (in ) /// so the tile — and its starting spinner — is visible while the launch spec is still being /// fetched. The host must wire its handlers and then call . public ConPtyPaneViewModel( string? taskId, string displayTitle, Func> descriptorFactory) { TaskId = taskId; _displayTitle = displayTitle; _descriptorFactory = descriptorFactory; Subscribe(Terminal); } // Terminal is swapped wholesale on Retry, so the handlers follow the property rather than being // wired once at construction. (The initial value comes from the field initializer, which never // runs through the setter -- hence the explicit Subscribe in the constructor.) partial void OnTerminalChanging(InteractiveTerminalViewModel? oldValue, InteractiveTerminalViewModel newValue) { if (oldValue is not null) Unsubscribe(oldValue); } partial void OnTerminalChanged(InteractiveTerminalViewModel value) => Subscribe(value); private void Subscribe(InteractiveTerminalViewModel terminal) { terminal.PropertyChanged += OnTerminalPropertyChanged; terminal.ExitedAfterStartup += OnTerminalExitedAfterStartup; } private void Unsubscribe(InteractiveTerminalViewModel terminal) { terminal.PropertyChanged -= OnTerminalPropertyChanged; terminal.ExitedAfterStartup -= OnTerminalExitedAfterStartup; } private void OnTerminalExitedAfterStartup(int exitCode) { if (TaskId is { } id) SessionDiedAfterStartup?.Invoke(id, exitCode); } private readonly Func> _descriptorFactory; /// Resolves the launch spec and spawns the session. Call after wiring /// so a failed launch is not swallowed. public void Start() => _ = StartAsync(); private async System.Threading.Tasks.Task StartAsync() { try { Terminal.Start(await _descriptorFactory()); } catch (Exception ex) { Terminal.ReportStartFailure(ex.Message); } } /// Ad-hoc pane — no task, no dedup. public static ConPtyPaneViewModel CreateAdHoc( string displayTitle, Func> descriptorFactory) => new(null, displayTitle, descriptorFactory); private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error) ErrorReported?.Invoke(error); if (e.PropertyName is nameof(InteractiveTerminalViewModel.IsStarting) or nameof(InteractiveTerminalViewModel.StartError) or nameof(InteractiveTerminalViewModel.HasExited)) { SubmitForReviewCommand.NotifyCanExecuteChanged(); RetryCommand.NotifyCanExecuteChanged(); } } /// Types a message into this pane's live session and submits it (the worker's /// pre-usage-limit warning). False when the session isn't running. public System.Threading.Tasks.Task SendLineAsync(string text) => Terminal.SendLineAsync(text); [RelayCommand] private void Close() => CloseRequested?.Invoke(this); private bool CanSubmitForReview() => IsTaskBased && !IsSubmitPending && !Terminal.IsStarting && Terminal.StartError is null && !Terminal.HasExited; [RelayCommand(CanExecute = nameof(CanSubmitForReview))] private void SubmitForReview() { if (TaskId is { } id) SubmitForReviewRequested?.Invoke(id); } partial void OnIsSubmitPendingChanged(bool value) => SubmitForReviewCommand.NotifyCanExecuteChanged(); // A launch failure permanently occupies the TaskId dedupe slot unless the user can retry — // re-opening the same task would otherwise just re-focus a dead tile. private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null; [RelayCommand(CanExecute = nameof(CanRetry))] private void Retry() { var dead = Terminal; Terminal = new InteractiveTerminalViewModel(); // OnTerminalChanging/Changed move the handlers dead.Dispose(); Start(); } public void Dispose() { Unsubscribe(Terminal); Terminal.Kill(); Terminal.Dispose(); } }