Skip to content

fix: Ctrl+C double-exit in interactive shell#1105

Merged
VaibhavUpreti merged 5 commits intoTracer-Cloud:mainfrom
jason8745:issue/1091-ctrl-c-double-exit-shell
Apr 30, 2026
Merged

fix: Ctrl+C double-exit in interactive shell#1105
VaibhavUpreti merged 5 commits intoTracer-Cloud:mainfrom
jason8745:issue/1091-ctrl-c-double-exit-shell

Conversation

@jason8745
Copy link
Copy Markdown
Contributor

@jason8745 jason8745 commented Apr 30, 2026

Fixes #1091

Changes

Implements the two-press Ctrl+C exit pattern, consistent with Claude CLI:

  • First Ctrl+C: displays (Press Ctrl+C again to exit) and re-displays the prompt
  • Second Ctrl+C within 2 seconds: prints Goodbye! and exits cleanly (exit code 0)

Implementation

  • Added _with_ctrl_c_double_exit() in app/cli/prompt_support.py — wraps question.unsafe_ask() in a retry loop. This avoids key-binding conflicts that caused an earlier sentinel approach to fail (Application.exit() raises if called twice).
  • Added _HardQuitInterrupt(KeyboardInterrupt) subclass so Ctrl+Q hard-quit bypasses the retry guard.
  • Restored an explicit ControlC key binding in wizard _base_bindings()InquirerControl is not a BufferControl, so prompt_toolkit's default Ctrl+C binding is inactive for custom wizard prompts; without this, \x03 was silently ignored.
  • Installed a SIGINT handler in main() via _install_sigint_handler() to handle Ctrl+C between prompts (streaming output, long-running ops, etc.).
  • Updated unit tests in tests/cli/test_prompt_support.py covering hint-on-first-press, exit-on-second-press, and window reset.
  • Fixed test_tests_interactive_launcher_smoke to use Escape instead of Ctrl+C — single Ctrl+C no longer exits immediately, and double Ctrl+C in PTY cooked mode has SIGINT race conditions. Escape is PTY-safe and already the documented exit key in the prompt instruction.

Verification

Before
image
After
image


Code Understanding and AI Usage

Did you use AI assistance (ChatGPT, Claude, Copilot, etc.) to write any part of this code?

  • No, I wrote all the code myself
  • Yes, I used AI assistance (continue below)

If you used AI assistance:

  • I have reviewed every single line of the AI-generated code
  • I can explain the purpose and logic of each function/component I added
  • I have tested edge cases and understand how the code handles them
  • I have modified the AI output to follow this project's coding standards and conventions

Explain your implementation approach:

The core insight is that question.unsafe_ask() propagates KeyboardInterrupt while question.ask() swallows it. By wrapping unsafe_ask() in a while True retry loop, the first Ctrl+C is caught, the hint is printed, and the same Application instance is re-run (safe because Application.run() resets _is_running and creates a new future on each call). The second Ctrl+C within the time window exits. This avoids fighting prompt_toolkit's key binding merge order entirely.


Checklist before requesting a review

  • I have added proper PR title and linked to the issue
  • I have performed a self-review of my code
  • I can explain the purpose of every function, class, and logic block I added
  • I understand why my changes work and have tested them thoroughly
  • I have considered potential edge cases and how my code handles them
  • If it is a core feature, I have added thorough tests
  • My code follows the project's style guidelines and conventions

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 30, 2026

Greptile Summary

This PR implements the Claude-CLI-style double-press Ctrl+C exit pattern: the first Ctrl+C shows a hint and re-displays the prompt; the second within 2 seconds prints "Goodbye!" and exits cleanly. The two contexts (inside a prompt_toolkit Application and between prompts via SIGINT) are handled separately but share a single _last_ctrl_c timestamp, which is the correct design.

Confidence Score: 5/5

Safe to merge; all remaining findings are P2 style/clarity suggestions that do not affect correctness on any realistic platform.

The implementation is well-reasoned and tested. The three findings are all P2: questionary.password omission (likely intentional), the 0.0 sentinel (practically harmless), and the broad KeyboardInterrupt catch (cosmetically inconsistent but doesn't break anything). No P0/P1 issues found.

No files require special attention, though reviewers may want to confirm the questionary.password omission is intentional.

Important Files Changed

Filename Overview
app/cli/prompt_support.py Core of the fix: adds _with_ctrl_c_double_exit, handle_ctrl_c_press, and install_questionary_ctrl_c_double_exit. Logic is sound; minor concerns around 0.0 sentinel and omission of questionary.password from the patch.
app/cli/main.py Installs the SIGINT handler and the double-exit patch at startup; adds a broad except KeyboardInterrupt: return 0 that silently swallows interrupts from unpatched paths.
app/cli/wizard/prompts.py Correctly splits the old shared _abort handler into separate _quit (ControlQ → _HardQuitInterrupt) and _ctrl_c (ControlC → KeyboardInterrupt) bindings, and wraps the returned Question with _with_ctrl_c_double_exit.
tests/cli/test_prompt_support.py New tests cover idempotency, hint-on-first-press, exit-on-second-press, and window reset. State isolation is handled correctly (each test that depends on _last_ctrl_c sets it at the start).
tests/cli_smoke_test.py Single-line change: replaces \x03 (Ctrl+C) with \x1b (Escape) in the PTY smoke test, avoiding SIGINT race conditions in cooked-mode PTYs.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User presses Ctrl+C] --> B{Where?}
    B -->|Inside prompt_toolkit Application| C[ControlC key binding fires]
    B -->|Between prompts / streaming output| D[SIGINT handler fires]

    C --> E["event.app.exit(exception=KeyboardInterrupt)"]
    E --> F["_patched_ask catches KeyboardInterrupt"]
    D --> G["handle_ctrl_c_press()"]

    F --> H{_last_ctrl_c within 2s?}
    G --> H

    H -->|No - first press| I["Print hint\n(Press Ctrl+C again to exit)\nUpdate _last_ctrl_c"]
    H -->|Yes - second press| J["Print Goodbye!\nsys.exit(0)"]

    I -->|In _patched_ask| K[Loop: re-run unsafe_ask]
    I -->|In SIGINT handler| L[Return to caller]
    J --> M[SystemExit 0]

    K --> N[Prompt re-displayed]

    subgraph Ctrl+Q path hard quit
        O[ControlQ key binding] --> P["event.app.exit(exception=_HardQuitInterrupt)"]
        P --> Q["_patched_ask re-raises _HardQuitInterrupt\n(bypasses retry loop)"]
    end
Loading

Reviews (1): Last reviewed commit: "fix: Ctrl+C double-exit in interactive s..." | Re-trigger Greptile

Comment thread app/cli/prompt_support.py
Comment thread app/cli/prompt_support.py
Comment thread app/cli/__main__.py
…nd exits)

Implements the two-press Ctrl+C exit pattern: the first Ctrl+C displays
"(Press Ctrl+C again to exit)" and re-displays the prompt; the second
Ctrl+C within 2 seconds prints "Goodbye!" and exits cleanly (code 0).

- Add `_with_ctrl_c_double_exit()` wrapping `question.unsafe_ask()` in a
  retry loop — avoids key-binding conflicts that caused the sentinel
  approach to fail
- Add `_HardQuitInterrupt(KeyboardInterrupt)` so Ctrl+Q hard-quit bypasses
  the retry guard
- Restore explicit ControlC binding in wizard `_base_bindings()` because
  `InquirerControl` is not a `BufferControl`, making prompt_toolkit's
  default Ctrl+C binding inactive for custom wizard prompts
- Install a SIGINT handler in `main()` for between-prompt Ctrl+C
- Add/update unit tests in `tests/cli/test_prompt_support.py`
- Fix smoke test to use Escape (PTY-safe) instead of Ctrl+C

Fixes Tracer-Cloud#1091
@jason8745 jason8745 force-pushed the issue/1091-ctrl-c-double-exit-shell branch from 72866ae to 132a0dd Compare April 30, 2026 09:06
@jason8745
Copy link
Copy Markdown
Contributor Author

Already reviewed and solved the greptile-apps comments

Comment thread app/cli/prompt_support.py Outdated
try:
# unsafe_ask() propagates KeyboardInterrupt instead of
# swallowing it the way ask() does.
return question.unsafe_ask(*args, **kwargs)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reset _last_ctrl_c after a successful prompt return. The current timestamp leaks across prompts, so Ctrl+C in prompt A followed by one Ctrl+C in prompt B within 2s exits unexpectedly.

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.

Thanks for the suggestion. Fixed in cc78f2e

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

good catch @muddlebee !

Comment thread app/cli/__main__.py

try:
cli(args=argv, standalone_mode=True)
except KeyboardInterrupt:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This exits on the first KeyboardInterrupt for unpatched prompt paths, which bypasses the double-press behavior from issue #1091. Reuse handle_ctrl_c_press here so Ctrl+C behavior stays consistent.

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.

Thanks for the suggestion! I want to make sure I understand the intent before implementing.

My concern is that calling handle_ctrl_c_press() here and then immediately return 0 would show (Press Ctrl+C again to exit) but exit the process right after — which seems more misleading than the current clean-exit behavior.

To make the double-press actually work here, we'd need a retry loop that re-runs cli(), but that restarts the entire CLI rather than re-displaying the interrupted prompt, which feels unexpected.

Could you clarify what behavior you had in mind? Were you thinking of a retry loop approach, or accepting that the hint fires but the process exits after the first press? Would love to understand your reasoning before deciding on the right fix.

Thank you~

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.

If consistency is the priority, happy to call handle_ctrl_c_press() here. Trade-off: the hint (Press Ctrl+C again to exit) would still show but the process exits immediately after — we can't re-display the prompt from main(). Let me know if that's acceptable.

@jason8745 jason8745 requested a review from muddlebee April 30, 2026 10:03
Copy link
Copy Markdown
Member

@VaibhavUpreti VaibhavUpreti left a comment

Choose a reason for hiding this comment

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

Great job @jason8745 ! Looks good, welcome the the OpenSRE community 🚀

@VaibhavUpreti VaibhavUpreti merged commit 72dd2cc into Tracer-Cloud:main Apr 30, 2026
6 checks passed
@github-actions
Copy link
Copy Markdown
Contributor

🍕 @jason8745's PR: crispy edges, no unnecessary toppings, delivered on time. Understood the assignment. 🔥


👋 Join us on Discord - OpenSRE : hang out, contribute, or hunt for features and issues. Everyone's welcome.

@jason8745 jason8745 deleted the issue/1091-ctrl-c-double-exit-shell branch April 30, 2026 10:46
gitsofaryan pushed a commit to gitsofaryan/opensre that referenced this pull request May 3, 2026
* fix: Ctrl+C double-exit in interactive shell (first press hints, second exits)

Implements the two-press Ctrl+C exit pattern: the first Ctrl+C displays
"(Press Ctrl+C again to exit)" and re-displays the prompt; the second
Ctrl+C within 2 seconds prints "Goodbye!" and exits cleanly (code 0).

- Add `_with_ctrl_c_double_exit()` wrapping `question.unsafe_ask()` in a
  retry loop — avoids key-binding conflicts that caused the sentinel
  approach to fail
- Add `_HardQuitInterrupt(KeyboardInterrupt)` so Ctrl+Q hard-quit bypasses
  the retry guard
- Restore explicit ControlC binding in wizard `_base_bindings()` because
  `InquirerControl` is not a `BufferControl`, making prompt_toolkit's
  default Ctrl+C binding inactive for custom wizard prompts
- Install a SIGINT handler in `main()` for between-prompt Ctrl+C
- Add/update unit tests in `tests/cli/test_prompt_support.py`
- Fix smoke test to use Escape (PTY-safe) instead of Ctrl+C

Fixes Tracer-Cloud#1091

* fix: add questionary.password to Ctrl+C double-exit and Escape-cancel patches

* fix: use None sentinel for _last_ctrl_c to make never-pressed state explicit

* fix: print newline on unhandled KeyboardInterrupt in main() for clean terminal output

* fix: reset _last_ctrl_c after successful prompt return to prevent cross-prompt leakage
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.

Improve the interactive opensre shell behavior so that pressing Ctrl+C twice exits the shell, similar to Claude CLI.

3 participants