diff --git a/src/Capacitor.App/ViewModels/AgentStatusPresentation.cs b/src/Capacitor.App/ViewModels/AgentStatusPresentation.cs index 1d88a1dce..bec5c6fcb 100644 --- a/src/Capacitor.App/ViewModels/AgentStatusPresentation.cs +++ b/src/Capacitor.App/ViewModels/AgentStatusPresentation.cs @@ -13,6 +13,10 @@ public sealed record AgentStatusPresentation( public string AccessibleName => Tip.Length == 0 ? "" : Tip.Split('\n')[0]; public bool HasLabel => Label.Length > 0; + /// Working, and the daemon has proof a turn is running rather than an idle prompt that never + /// reported a turn end. Only this makes a mid-turn keystroke like Escape safe to send. + public bool InForegroundTurn { get; init; } + /// The daemon says Running but the turn is unknown (a remote row): live, yet not claimed as Working. public bool IsRunningUnknownTurn => !Ended && Kind == AgentStatusKind.Other && Label == "Running"; public bool IsInFlight => Kind is AgentStatusKind.Working or AgentStatusKind.Starting or AgentStatusKind.Resuming || IsRunningUnknownTurn; diff --git a/src/Capacitor.App/ViewModels/ChatSessionInfo.cs b/src/Capacitor.App/ViewModels/ChatSessionInfo.cs index 0208aac2c..4558a6d5f 100644 --- a/src/Capacitor.App/ViewModels/ChatSessionInfo.cs +++ b/src/Capacitor.App/ViewModels/ChatSessionInfo.cs @@ -18,7 +18,9 @@ public sealed record ChatSessionInfo( // Why the local daemon could not resume the agent after a restart. Null on the remote lane. string? Interruption = null, // When the local daemon saw the current turn start. Null on the remote lane and from an older daemon. - DateTime? WorkingSince = null) { + DateTime? WorkingSince = null, + // The local daemon's proof that a turn is running. Null on the remote lane and from an older daemon. + bool? TurnAttested = null) { /// True when this session is waiting on the user. Independent of the status word. public bool WaitsOnUser { get; init; } @@ -37,7 +39,7 @@ public sealed record ChatSessionInfo( // the borrowed checkout for a reviewer. dto.WorktreePath ?? dto.RepoPath, dto.AwaitingInput, ended || SessionStatusDots.IsTerminal(dto.Status), ChatTabViewModel.ParticipantNotice(dto), dto.TranscriptPath, - dto.LiveSubagents, dto.UsageLimit, dto.TerminalDialog, dto.Interruption, dto.WorkingSince) { + dto.LiveSubagents, dto.UsageLimit, dto.TerminalDialog, dto.Interruption, dto.WorkingSince, dto.TurnAttested) { WaitsOnUser = SessionStatusDots.WaitsOnUser(dto), TakesAttachments = dto.Kind == "agent", }; diff --git a/src/Capacitor.App/ViewModels/ChatTabViewModel.cs b/src/Capacitor.App/ViewModels/ChatTabViewModel.cs index 4215e7777..25e1ac1bc 100644 --- a/src/Capacitor.App/ViewModels/ChatTabViewModel.cs +++ b/src/Capacitor.App/ViewModels/ChatTabViewModel.cs @@ -434,6 +434,12 @@ public IReadOnlyList TerminalMenuChoices { readonly ObservableAsPropertyHelper _showsInterrupt; public bool ShowsInterrupt => _showsInterrupt.Value; + readonly ObservableAsPropertyHelper _canInterruptWithEscape; + public bool CanInterruptWithEscape => _canInterruptWithEscape.Value; + + readonly ObservableAsPropertyHelper _interruptLabel; + public string InterruptLabel => _interruptLabel.Value; + string _vendor = ""; IReadOnlyList _options = HostedHarnessCatalog.Build(null); @@ -495,6 +501,7 @@ private set { string _status = ""; bool _ended; bool? _awaitingInput; + bool _turnAttested; bool _waitsOnUser; int? _liveSubagents; long? _workingSince; @@ -560,7 +567,8 @@ void RefreshAgentStatus() { _status, _awaitingInput, _waitsOnUser, _liveSubagents, HasPendingCards, question ? _usageLimit!.Summary : null, stage, elapsed, answerExpected: answerExpected, ended: _ended, liveCommands: _runs.RunningCommandCount, - terminalDialog: _terminalDialog is not null, interruption: _interruption, agentId: _agentId); + terminalDialog: _terminalDialog is not null, interruption: _interruption, agentId: _agentId, + turnAttested: _turnAttested); StatusText = AgentStatus.Label; } @@ -748,6 +756,18 @@ public ChatTabViewModel( && (AgentStatus.Kind == AgentStatusKind.Working || AgentStatus.IsRunningUnknownTurn)) .DisposeWith(_disposables); + // The button still interrupts an unproven turn; only the shortcut, which a user presses + // without looking, needs the daemon's proof that a prompt is not sitting idle. + _canInterruptWithEscape = this.WhenAnyValue(x => x.ShowsInterrupt, x => x.AgentStatus, + (shows, status) => shows && status.InForegroundTurn) + .ToProperty(this, x => x.CanInterruptWithEscape, initialValue: false) + .DisposeWith(_disposables); + + _interruptLabel = this.WhenAnyValue(x => x.CanInterruptWithEscape, + escape => escape ? "Interrupt (Esc)" : "Interrupt") + .ToProperty(this, x => x.InterruptLabel, initialValue: "Interrupt") + .DisposeWith(_disposables); + // The banner carries the read-only explanation for a flow participant, so the hint // goes blank there instead of offering a reply that can never be sent. _composerHint = Observable.CombineLatest( @@ -937,6 +957,7 @@ void OnSession(ChatSessionInfo info) { if (info.Ended) foreach (var queued in Tracked.Where(q => !q.IsForeign)) queued.MarkUnconfirmed(); _awaitingInput = info.AwaitingInput; + _turnAttested = info.TurnAttested == true; _liveSubagents = info.LiveSubagents; _daemonWorkingSince = info.WorkingSince; if (!_feedReleased) _runs.SessionOver = info.Ended; diff --git a/src/Capacitor.App/ViewModels/SessionStatusDots.cs b/src/Capacitor.App/ViewModels/SessionStatusDots.cs index f79fa5de3..8911bdf5d 100644 --- a/src/Capacitor.App/ViewModels/SessionStatusDots.cs +++ b/src/Capacitor.App/ViewModels/SessionStatusDots.cs @@ -57,7 +57,8 @@ public static AgentStatusPresentation Present( string? usageLimitSummary, string? launchStage, string? elapsed, string? requester = null, string? borrowedFrom = null, bool answerExpected = false, string? model = null, string? harness = null, bool ended = false, int liveCommands = 0, - bool terminalDialog = false, string? interruption = null, string? agentId = null) { + bool terminalDialog = false, string? interruption = null, string? agentId = null, + bool turnAttested = false) { var kind = status == "Failed" ? AgentStatusKind.Failed : answerExpected ? AgentStatusKind.Answer @@ -112,7 +113,9 @@ public static AgentStatusPresentation Present( Add(facts, requester, "Requester"); Add(facts, borrowedFrom, "Borrowed from"); return new AgentStatusPresentation(kind, label, FormatTip(facts), - kind is AgentStatusKind.Working or AgentStatusKind.Starting or AgentStatusKind.Resuming, facts) { Ended = ended }; + kind is AgentStatusKind.Working or AgentStatusKind.Starting or AgentStatusKind.Resuming, facts) { + Ended = ended, InForegroundTurn = kind == AgentStatusKind.Working && turnAttested, + }; } static void Add(List facts, string? text, string? caption = null) { diff --git a/src/Capacitor.App/Views/ChatTabView.axaml b/src/Capacitor.App/Views/ChatTabView.axaml index 4d9901e25..fc0b2a110 100644 --- a/src/Capacitor.App/Views/ChatTabView.axaml +++ b/src/Capacitor.App/Views/ChatTabView.axaml @@ -473,7 +473,7 @@ -