Skip to content

Commit 55c0018

Browse files
assembly live: exit cleanly on Ctrl-C during TUI setup
A Ctrl-C during the voice TUI's setup — opening the mic, building the deepagents graph, loading --mcp-config servers — lands before Textual captures the keyboard, so it surfaced as a raw KeyboardInterrupt (and, mid asyncio.run/threading teardown, a noisy traceback). The line-renderer path already mapped this to a clean exit 130; the TUI dispatch did not. Extract a _launch_tui helper that wraps _run_live_tui and maps a setup-time KeyboardInterrupt to typer.Exit(130), matching the assembly code TUI. (In-session Ctrl-C is already a Textual binding, so it never reaches the graph as an exception.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent be20e9c commit 55c0018

2 files changed

Lines changed: 30 additions & 1 deletion

File tree

aai_cli/commands/agent_cascade/_exec.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,20 @@ def run_conversation(renderer: engine.Renderer) -> None:
247247
app.run(mouse=False)
248248

249249

250+
def _launch_tui(api_key: str, opts: AgentCascadeOptions, config: CascadeConfig) -> None:
251+
"""Run the voice-only TUI, mapping a setup-time Ctrl-C to a clean exit.
252+
253+
A Ctrl-C during setup — opening the mic, building the graph, loading ``--mcp-config``
254+
servers — lands before Textual captures the keyboard, so it surfaces as a plain
255+
``KeyboardInterrupt`` here. Map it to exit 130 (cancel) rather than letting it dump a
256+
half-initialized asyncio/threading traceback.
257+
"""
258+
try:
259+
_run_live_tui(api_key, opts, config)
260+
except KeyboardInterrupt:
261+
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
262+
263+
250264
def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode: bool) -> None:
251265
"""Execute one `assembly agent-cascade` cascade from already-parsed flags."""
252266
text_mode, json_mode = resolve_output_modes(opts.output_field, json_mode=json_mode)
@@ -290,7 +304,7 @@ def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode:
290304

291305
if _should_use_tui(from_file=from_file, json_mode=json_mode, text_mode=text_mode):
292306
# The voice-only Textual front-end surfaces the web-search note in-app, not on stderr.
293-
_run_live_tui(api_key, opts, config)
307+
_launch_tui(api_key, opts, config)
294308
return
295309

296310
_warn_without_web_search(json_mode=json_mode)

tests/test_live_tui.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import types
1414

1515
import pytest
16+
import typer
1617
from textual.widgets import Static
1718

1819
from aai_cli.agent_cascade import engine
@@ -418,6 +419,20 @@ def run(self, **kwargs):
418419
assert captured["ran"] == {"mouse": False} # mouse off so transcript text stays selectable
419420

420421

422+
def test_tui_setup_keyboard_interrupt_exits_clean(monkeypatch) -> None:
423+
# Ctrl-C during TUI setup (mic open / graph build / --mcp-config load) lands before
424+
# Textual captures the keyboard; it must exit 130, not surface a raw traceback.
425+
_wire_tui(monkeypatch)
426+
427+
def boom(*_a, **_k):
428+
raise KeyboardInterrupt
429+
430+
monkeypatch.setattr(_exec, "_run_live_tui", boom)
431+
with pytest.raises(typer.Exit) as exc:
432+
run_agent_cascade(_opts(), AppState(), json_mode=False)
433+
assert exc.value.exit_code == 130
434+
435+
421436
def test_tui_run_conversation_drives_the_cascade(monkeypatch) -> None:
422437
# The closure handed to the app runs the cascade with the duplex player and the wired
423438
# deps, and the cascade's on_session wires the session's reply-interrupt onto the app.

0 commit comments

Comments
 (0)