Skip to content

Restrict desktop Escape interruption to confirmed foreground work - #1852

Draft
nortonandreev wants to merge 2 commits into
mainfrom
fix/desktop-escape-interrupt
Draft

nortonandreev wants to merge 2 commits into
mainfrom
fix/desktop-escape-interrupt

Conversation

@nortonandreev

@nortonandreev nortonandreev commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

No GitHub or Linear issue was provided.

What & why

Escape in desktop chat interrupts only a confirmed foreground turn. Idle and unknown-state prompts, including parents waiting on background work, ignore the shortcut; child controls get the first chance to dismiss UI.

Where to look

Remote sessions with uncertain turn status retain the explicit Interrupt action. The button advertises Esc only when the shortcut is available.

Verification

  • LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 dotnet run --project test/Capacitor.App.Tests.Unit --no-restore --disable-build-servers -- --maximum-parallel-tests 4: 4,217 passed, 10 skipped on the final commit.
  • Regression tests reproduced unsafe Escape forwarding and child-handler interception before the fix.
  • Default locale exposes the existing decimal-format assertion in UiScaleTests.A_change_is_published_and_kept_for_the_next_start; it passes with English locale.
  • Solution-wide dotnet test failed test-process handshakes before executing tests.

Summary by CodeRabbit

  • Bug Fixes
    • Escape now interrupts a chat turn only when interruption is available, and controls can handle Escape before it reaches the chat.
    • Pressing Escape closes an open composer context menu first, and the draft remains intact when interrupting.
    • Remote turns without confirmed details can still be interrupted using the Interrupt button.
  • UI
    • The button label now shows “Interrupt (Esc)” only when Escape can interrupt; otherwise, it shows “Interrupt.”

@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: cb4e1ee0-5926-4dda-9731-a7fe37cb6c7d
📥 Commits

Reviewing files that changed from the base of the PR and between 98a7ba7 and c2067f4.

📒 Files selected for processing: 5
  • src/Capacitor.App/ViewModels/ChatTabViewModel.cs
  • src/Capacitor.App/Views/ChatTabView.axaml
  • src/Capacitor.App/Views/ChatTabView.axaml.cs
  • test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs
  • test/Capacitor.App.Tests.Unit/RemoteSessionViewSmokeTests.cs

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Walkthrough

Escape now interrupts only when the view model confirms the shortcut is available. The Interrupt button label reflects that availability. Tests cover Escape handling in the composer and remote sessions with unknown turn state.

Changes: Escape interrupt behavior

Layer / File(s) Summary
Shortcut availability and button label
src/Capacitor.App/ViewModels/ChatTabViewModel.cs, src/Capacitor.App/Views/ChatTabView.axaml
The view model exposes whether Escape can interrupt and derives the button label from that condition. The button displays “Interrupt (Esc)” when the shortcut is available and “Interrupt” otherwise.
Escape routing and coverage
src/Capacitor.App/Views/ChatTabView.axaml.cs, test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs, test/Capacitor.App.Tests.Unit/RemoteSessionViewSmokeTests.cs
The composer no longer handles bare Escape to interrupt. The view checks shortcut availability before running the interrupt command. Tests cover awaiting-input and subagent states, child control handling, context-menu dismissal, and explicit interruption when a remote turn’s state is unknown.

Priority: ⚪ Not assessed

Estimated code review effort: 2 (Simple) | ~12 minutes

Change: Bug fix

Possibly related issues

  • kurrent-io/kcap-cli#1824: The change distinguishes Escape availability from explicit interrupt visibility, but does not change the reported idle status flicker.
  • kurrent-io/kcap-cli#1801: The change keeps explicit interruption available for an unknown remote turn while disabling the Escape shortcut.

Suggested reviewers: alexeyzimarev

Merge Risk: ⚪ Minimal · up to c2067

Escape remains limited to confirmed foreground work, while the explicit Interrupt button remains available when turn status is uncertain. No issue identified here prevents merging.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 4 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: limiting desktop Escape interruption to confirmed foreground work.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 4 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Restrict desktop Escape to confirmed foreground turns

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Ignore Escape when turn state is unknown or the parent is waiting on background work.
• Let child controls dismiss UI before chat handles Escape; preserve explicit interruption for
 remote sessions.
• Show the Esc hint only when available and add keyboard and remote-session regression tests.
Diagram

graph TD
  A["Escape key"] --> B["Child controls"] -->|Unhandled| C["Chat view"] --> D{"Foreground turn?"} -->|Yes| E["Interrupt command"] --> F["Session channel"]
  D -->|No| G["Leave unchanged"]
  H["Interrupt button"] -->|Explicit action| E
Loading
High-Level Assessment

Keep the shortcut gate separate from command availability: disabling the command would also remove the explicit action needed for uncertain remote turns. Checking the nullable awaiting-input state preserves that distinction, while bubbling gives child controls first refusal.

Files changed (5) +118 / -15

Bug fix (3) +14 / -7
ChatTabViewModel.csDistinguish Escape eligibility from interrupt availability +11/-0

Distinguish Escape eligibility from interrupt availability

• Adds a shortcut gate requiring an available interrupt and an explicitly confirmed foreground turn. Exposes a reactive button label that includes “Esc” only when that gate is open.

src/Capacitor.App/ViewModels/ChatTabViewModel.cs

ChatTabView.axamlBind the interrupt button to its conditional shortcut label +1/-1

Bind the interrupt button to its conditional shortcut label

• Replaces the unconditional Esc hint with the view model’s label, so the button does not advertise an unavailable shortcut.

src/Capacitor.App/Views/ChatTabView.axaml

ChatTabView.axaml.csHandle eligible Escape presses after child controls +2/-6

Handle eligible Escape presses after child controls

• Removes composer-level tunneling of Escape and gates the chat view’s bubbling interrupt handler on confirmed foreground work. Child controls can consume Escape before an interruption is attempted.

src/Capacitor.App/Views/ChatTabView.axaml.cs

Tests (2) +104 / -8
ChatTabViewSmokeTests.csCover idle, unknown, and child-handled Escape behavior +95/-0

Cover idle, unknown, and child-handled Escape behavior

• Adds regressions for unknown or waiting parent turns, transitions into and out of foreground work, child key handlers, and context-menu dismissal. Checks that suppressed shortcuts preserve the draft and do not reach the terminal.

test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs

RemoteSessionViewSmokeTests.csVerify explicit interruption for unknown remote turns +9/-8

Verify explicit interruption for unknown remote turns

• Revises the remote-session test to assert that Escape sends nothing and the button reads “Interrupt” when turn details are unknown. It confirms the button still sends an explicit interruption until the session becomes idle or disconnects.

test/Capacitor.App.Tests.Unit/RemoteSessionViewSmokeTests.cs

@nortonandreev nortonandreev self-assigned this Oct 22, 2026
@nortonandreev

Copy link
Copy Markdown
Contributor Author
  • Escape interrupts the agent only when it is actively working on a response. Your session and draft stay available so you can continue chatting.
  • Escape leaves idle sessions alone, including when background tasks are still running or the app cannot tell whether a response is in progress.
  • Menus and other controls get the first chance to handle Escape, so dismissing them does not also interrupt the agent.
  • The Interrupt button shows “Esc” only when the keyboard shortcut is available. You can still click Interrupt when a remote session’s activity is uncertain.

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

Tip of the day
💡 Did you know, you can ask Qodo to dismiss a finding you disagree with, with your reason on record

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@nortonandreev nortonandreev left a comment

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.

The main issue is the first comment on ChatTabViewModel.cs: AwaitingInput == false is also what an idle PTY prompt reports, so Escape can still reach one. The rest are refresh fragility, a latent Escape bubbling change, and two tests that don't pin the new gate. Moving this to draft until the first comment is resolved.

public bool ShowsInterrupt => _showsInterrupt.Value;

// Running alone, or background work while the parent waits, does not make Escape safe at its prompt.
public bool CanInterruptWithEscape => ShowsInterrupt && _awaitingInput == false;

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.

AwaitingInput == false isn't proof of a foreground turn: the daemon also reports false for a PTY agent idle at its prompt.

  • Launched without a prompt: AgentActivityClock._awaitingInput starts false and only a relayed Stop (or the ACP turn gate's falling edge) sets it true.
  • A vendor whose hooks never relay Stop reports false for its whole life.
  • Every submit clears it (IsSubmit → ClearAwaitingInputSince), so a submit that starts no turn (a local /help) leaves it false with no Stop to follow.

In each case the status is Working, the button reads "Interrupt (Esc)", and Escape sends 0x1b to an idle prompt, which is the case this PR sets out to prevent.

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 in c690b4f. The daemon now sends TurnAttested, which is true only while an ACP turn gate is held, or after a relayed UserPromptSubmit/PreToolUse hook with no Stop or quiet mark since. A delivered input or a keyed submit never sets it, so a launch without a prompt, a vendor that never relays Stop, and a local /help all leave it false. Escape requires it; the button still works without it.

readonly ObservableAsPropertyHelper<bool> _showsInterrupt;
public bool ShowsInterrupt => _showsInterrupt.Value;

// Running alone, or background work while the parent waits, does not make Escape safe at its prompt.

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.

This rule restates one disjunct of SessionStatusDots.IsWorking (awaitingInput == false || liveSubagents > 0 || liveCommands > 0) from a raw field. Exposing "confirmed foreground turn" as one fact on AgentStatusPresentation, next to IsWorking/IsRunningUnknownTurn, would keep the rail, notifications and the interrupt chip agreeing, and would let this become a reactive property folded into the _showsInterrupt pipeline instead of a non-notifying getter.

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.

Done. InForegroundTurn is now on AgentStatusPresentation, set by SessionStatusDots.Present (Working and attested). CanInterruptWithEscape is a reactive property built from ShowsInterrupt and AgentStatus.

&& (AgentStatus.Kind == AgentStatusKind.Working || AgentStatus.IsRunningUnknownTurn))
.DisposeWith(_disposables);

_interruptLabel = this.WhenAnyValue(x => x.ShowsInterrupt, x => x.AgentStatus,

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.

The selector ignores its inputs and reads CanInterruptWithEscape, which depends on the plain field _awaitingInput. The label refreshes only because AgentStatus is a fresh, unequal record on every refresh (its Facts list compares by reference). If the record becomes value-equal, an AwaitingInput flip with LiveSubagents = 1 keeps the same Kind and facts, so nothing fires and the label disagrees with what Escape does.

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. InterruptLabel now derives from the reactive CanInterruptWithEscape, and InForegroundTurn is part of the presentation record's value, so a flip in attestation alone changes the record.

@@ -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.


[Test]
[Arguments(null, 0)]
[Arguments(true, 0)]

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.

(true, 0) passed before this PR: the status is Idle, ShowsInterrupt is already false, and InterruptCommand.CanExecute already blocked Escape. Reverting the CanInterruptWithEscape check leaves this row green. Rows for the AwaitingInput == false-at-an-idle-prompt cases would pin the gap above.

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.

Replaced. The rows are now (false, null, 0), (false, false, 0) and (true, false, 1): Working with no attestation, which passes only because of the new gate. Each row also asserts ShowsInterrupt is true and CanInterruptWithEscape false before the key press.

}

[Test]
[NotInParallel("AvaloniaSession")]

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.

Nothing asserts the precondition that ShowsInterrupt and CanInterruptWithEscape are true when Escape is pressed, and there's no settle after AddOrUpdate. If the status hasn't reached the view model yet, SentInput stays empty whether or not the child handler ran, and the test passes without proving the handler ordering.

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. The child-handler and context-menu tests now settle after AddOrUpdate and assert CanInterruptWithEscape is true before pressing Escape.

@nortonandreev
nortonandreev marked this pull request as draft October 22, 2026 17:46
coderabbitai[bot]
coderabbitai Bot previously approved these changes Oct 22, 2026
An idle PTY prompt also reports AwaitingInput false, so the daemon now sends
TurnAttested: true only after an ACP turn gate or a relayed submit or tool-call
hook, until the next turn end or quiet mark.
@nortonandreev
nortonandreev force-pushed the fix/desktop-escape-interrupt branch from c690b4f to ac0d7a7 Compare October 23, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant