Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Capacitor.App/ViewModels/AgentStatusPresentation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions src/Capacitor.App/ViewModels/ChatSessionInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand All @@ -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",
};

Expand Down
23 changes: 22 additions & 1 deletion src/Capacitor.App/ViewModels/ChatTabViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,12 @@ public IReadOnlyList<TerminalMenuChoiceViewModel> TerminalMenuChoices {
readonly ObservableAsPropertyHelper<bool> _showsInterrupt;
public bool ShowsInterrupt => _showsInterrupt.Value;

readonly ObservableAsPropertyHelper<bool> _canInterruptWithEscape;
public bool CanInterruptWithEscape => _canInterruptWithEscape.Value;

readonly ObservableAsPropertyHelper<string> _interruptLabel;
public string InterruptLabel => _interruptLabel.Value;

string _vendor = "";
IReadOnlyList<HarnessOption> _options = HostedHarnessCatalog.Build(null);

Expand Down Expand Up @@ -495,6 +501,7 @@ private set {
string _status = "";
bool _ended;
bool? _awaitingInput;
bool _turnAttested;
bool _waitsOnUser;
int? _liveSubagents;
long? _workingSince;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions src/Capacitor.App/ViewModels/SessionStatusDots.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<AgentStatusFact> facts, string? text, string? caption = null) {
Expand Down
2 changes: 1 addition & 1 deletion src/Capacitor.App/Views/ChatTabView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@
</Button>
<TextBlock Grid.Column="1" Text="{Binding ComposerHint}" FontSize="12.5" Foreground="{StaticResource KcapFaintBrush}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<Button x:Name="InterruptButton" Grid.Column="2" Classes="kcapChip" Content="Interrupt (Esc)"
<Button x:Name="InterruptButton" Grid.Column="2" Classes="kcapChip" Content="{Binding InterruptLabel}"
Command="{Binding InterruptCommand}" IsVisible="{Binding ShowsInterrupt}"
ToolTip.Tip="Interrupt the current turn without ending the session" Margin="12,0,0,0"
BorderThickness="1" Height="24" Padding="10,0" VerticalContentAlignment="Center"
Expand Down
13 changes: 6 additions & 7 deletions src/Capacitor.App/Views/ChatTabView.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -608,11 +608,6 @@ bool ListTextBoxOwnsFocus() {
/// TextBox's own newline. ↑/↓ recall sent prompts only from the first/last line, so inside a
/// multi-line recall they stay the TextBox's own caret moves until the caret reaches an edge.
void OnComposerKeyDown(object? sender, KeyEventArgs e) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the tunnel handler gone, a bare Escape in the composer is no longer consumed when there's nothing to interrupt: TryInterrupt returns false and the key bubbles past ChatTabView to every ancestor. Nothing handles it there today, so this is latent, but a later window, dock or tab-host Escape binding would fire while the user is just typing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. ChatTabView.OnKeyDown now consumes a bare Escape that comes from the composer even when there is nothing to interrupt. The idle-Escape smoke test asserts the window never sees it.

if (e.Key == Key.Escape && e.KeyModifiers == KeyModifiers.None) {
e.Handled = true;
TryInterrupt();
return;
}
if (e.Key is Key.Up or Key.Down && e.KeyModifiers == KeyModifiers.None) {
if (DataContext is not ChatTabViewModel tab || ComposerInput.SelectionStart != ComposerInput.SelectionEnd) return;
var (line, lines) = ComposerCaretLine();
Expand All @@ -631,11 +626,15 @@ void OnComposerKeyDown(object? sender, KeyEventArgs e) {
protected override void OnKeyDown(KeyEventArgs e) {
base.OnKeyDown(e);
if (e.Handled || e.Key != Key.Escape || e.KeyModifiers != KeyModifiers.None || OriginatesFromPendingCard(e)) return;
if (TryInterrupt()) e.Handled = true;
// A composer Escape stops here even with nothing to interrupt, so an ancestor's Escape
// binding never fires while the user is typing.
if (TryInterrupt() || e.Source is Visual source && (source == ComposerInput || ComposerInput.IsVisualAncestorOf(source)))
e.Handled = true;
}

bool TryInterrupt() {
if (DataContext is not ChatTabViewModel chat || !((ICommand)chat.InterruptCommand).CanExecute(null)) return false;
if (DataContext is not ChatTabViewModel chat || !chat.CanInterruptWithEscape
|| !((ICommand)chat.InterruptCommand).CanExecute(null)) return false;
chat.InterruptCommand.Execute().Subscribe();
return true;
}
Expand Down
7 changes: 6 additions & 1 deletion src/Capacitor.Cli.Core/LocalIpc/StatusIpc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ public sealed record AgentStatusDto(
string? Interruption = null,
// UTC instant the daemon last saw the agent stop waiting, while Running and not waiting. Lets a
// freshly built view show the turn's real elapsed time. Null from an older daemon.
DateTime? WorkingSince = null);
DateTime? WorkingSince = null,
// The daemon has positive evidence that a turn is running: an ACP turn gate, or a PTY vendor's
// relayed prompt submit or tool call since its last turn end. AwaitingInput == false is not
// that evidence; an idle PTY prompt reports it too. False and null (an older daemon) both mean
// unproven, so a client must not send a keystroke that is safe only mid-turn.
bool? TurnAttested = null);

/// Wire tokens for <see cref="AgentStatusDto.WorkLocation"/>, compared literally by every
/// client, so they never change.
Expand Down
35 changes: 30 additions & 5 deletions src/Capacitor.Cli.Daemon/Services/AgentActivityClock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ internal sealed class AgentActivityClock(TimeProvider time) {
// it. A hook verdict clears the bit and then owns the flag.
bool _silenceWait;
long? _viewerInputAt;
// A relayed prompt submit or tool call with no turn end since. Output and a cleared wait also
// describe an idle prompt, so this is a PTY vendor's only proof a turn is running.
bool _hookTurn;
ulong _waitGeneration;
// When the flag last became false. Silence is measured from here as well as from the last
// output: a key that clears the wait must not be undone by the idle time already elapsed.
Expand Down Expand Up @@ -67,6 +70,17 @@ public bool AwaitingInput {
/// change, with the new value.</summary>
public Action<bool>? OnAwaitingInputChanged { get; set; }

/// <summary>Positive evidence that a turn is running now: the ACP turn gate held, or a PTY
/// vendor's relayed prompt submit or tool call with no turn end or quiet mark since. False
/// means unproven, not idle: a delivered input or a keyed submit never sets it, because a
/// submit that starts no turn (a local slash command) looks the same.</summary>
public bool TurnAttested {
get { lock (_gate) return _turnInFlight || _hookTurn; }
}

/// <summary>Fires outside <see cref="_gate"/> on every genuine <see cref="TurnAttested"/> change.</summary>
public Action? OnTurnAttestedChanged { get; set; }

/// <summary>Counts every observed turn end, whether or not it moved the flag: a PTY vendor
/// relays only Stop, so a wait nothing cleared sees the next turn end as a second Stop with the
/// flag already true. A delivery samples it before writing and clears through
Expand Down Expand Up @@ -199,13 +213,17 @@ public void Advance() {
/// <summary>The turn ended without a stop notice sticking: no output and no live subagent for
/// <paramref name="quiet"/>. A later <see cref="Advance"/> clears it; a hook verdict replaces it.</summary>
public bool TryMarkQuiet(TimeSpan quiet) {
bool attestedChanged;
lock (_gate) {
if (DueLocked(quiet) is not { } due || due > TimeSpan.Zero) return false;
attestedChanged = _hookTurn;
_silenceWait = true;
_awaitingInput = true;
_hookTurn = false;
_waitGeneration++;
return true;
}
if (attestedChanged) OnTurnAttestedChanged?.Invoke();
return true;
}

// Caller must hold _gate. Null when silence must not be applied.
Expand Down Expand Up @@ -244,8 +262,9 @@ bool ClearSilenceLocked() {
/// <summary>Turn start/end — also counts as activity, independent of accompanying envelope
/// traffic.</summary>
public void SetTurnInFlight(bool value) {
bool ended, awaitingChanged;
bool ended, awaitingChanged, attestedChanged;
lock (_gate) {
var attested = _turnInFlight || _hookTurn;
ended = _turnInFlight && !value;
_turnInFlight = value;
// Only a genuine falling edge means a turn finished; a gate cleared without ever being
Expand All @@ -257,30 +276,36 @@ public void SetTurnInFlight(bool value) {
if (!awaiting) _notAwaitingSince = time.GetTimestamp();
if (_awaitingInput && !awaiting) _workingSince = _notAwaitingSince;
_awaitingInput = awaiting;
_hookTurn = false;
attestedChanged = attested != value;
AdvanceLocked();
}
// Outside the lock, same rule as OnLaunchStageChanged: the callback's send reads this
// clock's own (independently guarded) properties.
if (ended) OnTurnEnded?.Invoke();
if (awaitingChanged) OnAwaitingInputChanged?.Invoke(!value);
if (attestedChanged) OnTurnAttestedChanged?.Invoke();
}

/// <summary>The explicit path for sources that see no turn gate: a PTY vendor's hook relay
/// and a delivered input. Moves the flag alone — see <see cref="AwaitingInput"/>.</summary>
/// <summary>A PTY vendor's relayed hook verdict: Stop is true, a prompt submit or tool call
/// false. Moves the wait and <see cref="TurnAttested"/> alone — see <see cref="AwaitingInput"/>.</summary>
public void SetAwaitingInput(bool value) {
bool changed;
bool changed, attestedChanged;
lock (_gate) {
// A hook verdict replaces a silence wait, so later output cannot clear a real stop.
_silenceWait = false;
changed = _awaitingInput != value;
attestedChanged = !_turnInFlight && _hookTurn == value;
if (value) _waitGeneration++;
else {
_notAwaitingSince = time.GetTimestamp();
if (changed) _workingSince = _notAwaitingSince;
}
_awaitingInput = value;
_hookTurn = !value;
}
if (changed) OnAwaitingInputChanged?.Invoke(value);
if (attestedChanged) OnTurnAttestedChanged?.Invoke();
}

/// <summary>Clears the wait a delivery answered, unless a newer one began since the caller
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ [.. _agents.Values
UsageLimit: a.Status == "Running" ? a.UsageLimit : null,
TerminalDialog: a.Status == "Running" ? a.TerminalDialog : null,
WorkingSince: a.Status == "Running" && a.ActivityClock.WorkingFor is { } working
? (_time.GetUtcNow() - working).UtcDateTime : null))];
? (_time.GetUtcNow() - working).UtcDateTime : null,
TurnAttested: a.Status == "Running" && a.ActivityClock.TurnAttested))];

/// The checkout is resolved as the live agent's is, so a row keeps its rail group across the handoff.
static AgentStatusDto LedgerStatusRow(ResumeLedgerEntry entry, string status) {
Expand Down
1 change: 1 addition & 0 deletions src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2192,6 +2192,7 @@ AgentActivityClock CreateActivityClock() =>
_statusNotifier.Pulse();
RescheduleQuietTurn();
},
OnTurnAttestedChanged = _statusNotifier.Pulse,
};

/// <summary>Test-only seam — insert a minimal <see cref="AgentInstance"/> (Noop
Expand Down
2 changes: 1 addition & 1 deletion test/Capacitor.App.Tests.Unit/ChatComposerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ await RunOnUiAsync(async () => {
await Assert.That(interruptButton.IsEffectivelyVisible).IsFalse();
await Assert.That(chat.ComposerHint).IsEqualTo("Enter sends · Shift+Enter for a new line");

daemon.Agents.AddOrUpdate(Agent("a1", "claude", hasTerminal: true, repoPath: "/repo") with { Status = "Running", AwaitingInput = false });
daemon.Agents.AddOrUpdate(Agent("a1", "claude", hasTerminal: true, repoPath: "/repo") with { Status = "Running", AwaitingInput = false, TurnAttested = true });
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();

Expand Down
Loading
Loading