Skip to content

feat: Ctrl+C interrupts first, exits on second press (#71)#72

Merged
yasha-dev1 merged 1 commit into
mainfrom
feat/ctrl-c-interrupt-first
Jun 16, 2026
Merged

feat: Ctrl+C interrupts first, exits on second press (#71)#72
yasha-dev1 merged 1 commit into
mainfrom
feat/ctrl-c-interrupt-first

Conversation

@yasha-dev1

Copy link
Copy Markdown
Collaborator

Closes #71.

Problem

Ctrl+C exited the REPL immediately. When the LLM is mid-generation you could interrupt with Esc, but Ctrl+C — the reflex for "stop" — quit the whole conversation.

Fix

Make Ctrl+C two-stage (matching Node/Python/Claude Code conventions):

  1. First Ctrl+C → emit interrupt (the interactive REPL aborts the in-flight run; a no-op when idle) and arm a short window, showing (press Ctrl+C again to exit).
  2. Second Ctrl+C within the window (2s)exit.
  3. The window auto-disarms, so a single accidental press never quits.
  • Ctrl+D still exits immediately (EOF) — unchanged.
  • While a modal key-capture is pending (the y/a/n permission prompt, which owns the keyboard and manages its own lifecycle), the original immediate exit is preserved.

Implementation is entirely in input.ts (the textarea owns the keyboard). The disarm timer is unref()'d and cleared on close.

Note on rebase

Rebased onto latest main — it now includes the merged bracketed-paste change (#60/#70). Both features coexist in onKeypress (paste capture runs first, then the Ctrl+C handler).

Tests

  • textarea-ctrl-c.test.ts (unit, 6): interrupt-first / second-exits / window-disarm (fake timers) / typing-between-presses / Ctrl+D immediate / modal-capture immediate-exit.
  • ctrl-c-interrupt.e2e.test.ts (integration, real REPL + agent loop): first Ctrl+C aborts the run and stays in the conversation (run-mode promise unresolved); second Ctrl+C exits.
  • textarea-interrupt.test.ts: updated the stale "ctrl+c exits" assertion to the new contract.
  • steering.e2e.test.ts: cleanup exits switched to Ctrl+D (since Ctrl+C now interrupts).

Verification

  • Full suite: 719 passing, lint + typecheck clean.
  • Timing-based e2e re-run 3× — stable.

🤖 Generated with Claude Code

Ctrl+C exited the REPL immediately. Now the first Ctrl+C interrupts the
in-flight run (like Esc) and arms a short window; a second Ctrl+C inside the
window exits. So you can stop the LLM mid-thought without leaving the
conversation, and a single accidental press never quits. Ctrl+D still exits
immediately (EOF). While a modal key-capture is pending (the y/a/n permission
prompt), the original immediate exit is preserved.

- input.ts: two-stage Ctrl+C (first → `interrupt` + "(press Ctrl+C again to
  exit)" hint + 2s armed window; second → `exit`); Ctrl+D split out; window
  timer is unref'd and cleared on close.

Tests:
- textarea-ctrl-c.test.ts (unit): interrupt-first, second-exits, window
  disarm (fake timers), typing-between, Ctrl+D immediate, modal-capture
  immediate exit.
- ctrl-c-interrupt.e2e.test.ts (integration, real REPL): first Ctrl+C aborts
  the run and stays in the conversation; second exits.
- textarea-interrupt.test.ts: updated the stale "ctrl+c exits" assertion.
- steering.e2e.test.ts: cleanup exits use Ctrl+D (Ctrl+C now interrupts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@yasha-dev1

Copy link
Copy Markdown
Collaborator Author

✅ Verification

Full suite + typecheck + lint (clean)

Test Files  83 passed (83)
     Tests  719 passed (719)
  • npm run typecheck → pass (core + cli)
  • npm run lint → pass

Ctrl+C tests (7)

✓ packages/cli/tests/textarea-ctrl-c.test.ts        (6 tests)
✓ packages/cli/tests/ctrl-c-interrupt.e2e.test.ts   (1 test)

Behavior confirmed

  • First Ctrl+C → interrupt, not exit (unit + e2e: the run aborts, the run-mode promise stays unresolved → conversation alive).
  • Second Ctrl+C within 2s → exit (e2e: await done resolves).
  • Window disarms (fake timers): after 2s a single Ctrl+C interrupts again instead of exiting.
  • Ctrl+D still exits immediately.
  • Permission prompt (modal key-capture) keeps the original immediate exit.
  • The first press also shows (press Ctrl+C again to exit).

Flakiness

The timing-based e2e (steering.e2e + ctrl-c-interrupt.e2e, 7 tests) was re-run — stable.

Regressions handled

Existing steering e2e tests used a single Ctrl+C to exit at cleanup; switched those to Ctrl+D (immediate exit, unchanged by this PR) and updated the one stale textarea-interrupt assertion. Rebased onto latest main, so this composes cleanly with the merged bracketed-paste fix (#60/#70).

@yasha-dev1 yasha-dev1 added the PR OK Grok PR verified and review passed label Jun 16, 2026
@yasha-dev1

Copy link
Copy Markdown
Collaborator Author

PR OK — verified end-to-end (posted as comment; GitHub blocks self-approval).

Closes #71: Ctrl+C is now two-stage — interrupt first, exit only on a second press — exactly the requested behavior.

Verified the wiring on both sides:

  • input.ts: first Ctrl+C emits interrupt + arms a 2s window + shows (press Ctrl+C again to exit); a second press inside the window emits exit; the disarm timer auto-clears so a lone accidental press never quits.
  • interactive-mode.ts (existing handler, line ~1142): interrupt calls session.agent.abort() guarded by !agentBusy (no-op when idle) and prints ⎋ Interrupted — so the run stops without leaving the conversation, precisely matching the issue. exit closes the textarea and resolves the REPL.
  • Preserved behaviors: Ctrl+D still exits immediately (EOF); while a modal key-capture (y/a/n permission prompt) is pending, the original immediate exit is kept.
  • Lifecycle: disarm timer is unref()'d (won't hold the process open) and cleared on close(). Composes cleanly with the merged bracketed-paste handler after the rebase (paste capture runs first).

Verification: build ✓ · typecheck ✓ · lint ✓ · full suite 719/719. New tests cover interrupt-first / second-exits / window-disarm (fake timers) / typing-between-presses / Ctrl+D-immediate / modal-immediate, plus an e2e proving the first Ctrl+C aborts the run and stays in the conversation while the second exits. Re-ran the timing-based e2e 3× → 16/16 each; the two stale assertions (textarea-interrupt, steering cleanup → Ctrl+D) were correctly updated to the new contract.

Correct, conventional, well-tested. LGTM.

@yasha-dev1 yasha-dev1 merged commit 65aa26e into main Jun 16, 2026
2 checks passed
@yasha-dev1 yasha-dev1 deleted the feat/ctrl-c-interrupt-first branch June 16, 2026 11:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PR OK Grok PR verified and review passed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ctrl+C should interrupt first, exit only on second press (instead of exiting immediately)

1 participant