From 1c4cb4dc8a8941124d28dd484bc76e7026767396 Mon Sep 17 00:00:00 2001 From: 0xallam Date: Sun, 26 Apr 2026 00:34:44 -0700 Subject: [PATCH] feat(interface): show resume hint on user-initiated exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user shuts down a run (Ctrl+C in CLI, Ctrl+Q / quit dialog in TUI, or an uncaught exception during the scan), print a Rich panel telling them the exact command to pick up where they left off: strix --run-name The panel only appears when ``strix_runs//bus.json`` exists — i.e. the scan registered at least the root agent and has snapshot state worth resuming from. Suppressed when: * No run-name was assigned (Ctrl+C before sandbox bring-up). * The run dir doesn't exist or has no bus.json yet. Implementation: * ``strix/interface/utils.py`` gains ``format_resume_hint(run_name) -> Panel | None``. * ``cli.py`` calls it in the SIGINT/SIGTERM/SIGHUP handler before ``sys.exit(1)``, and in the ``except Exception`` arm before the re-raise. * ``tui.py:run_tui`` calls it in a ``finally`` after ``app.run_async()`` so the hint lands on the real terminal once Textual has restored it (whether the user pressed Ctrl+Q, confirmed the quit dialog, or the run completed naturally). Co-Authored-By: Claude Opus 4.7 (1M context) --- strix/interface/cli.py | 9 +++++++++ strix/interface/tui.py | 16 ++++++++++++++-- strix/interface/utils.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/strix/interface/cli.py b/strix/interface/cli.py index d382a80..d225e50 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -21,6 +21,7 @@ from strix.telemetry.tracer import Tracer, set_global_tracer from .utils import ( build_live_stats_text, + format_resume_hint, format_vulnerability_report, ) @@ -139,6 +140,10 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 def signal_handler(_signum: int, _frame: Any) -> None: tracer.cleanup() + hint = format_resume_hint(args.run_name) + if hint is not None: + console.print() + console.print(hint) sys.exit(1) atexit.register(cleanup_on_exit) @@ -212,6 +217,10 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 except Exception as e: console.print(f"[bold red]Error during penetration test:[/] {e}") + hint = format_resume_hint(args.run_name) + if hint is not None: + console.print() + console.print(hint) raise if tracer.final_scan_result: diff --git a/strix/interface/tui.py b/strix/interface/tui.py index 1e1d9af..3623271 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -35,7 +35,7 @@ from strix.config import load_settings from strix.interface.tool_components.agent_message_renderer import AgentMessageRenderer from strix.interface.tool_components.registry import get_tool_renderer from strix.interface.tool_components.user_message_renderer import UserMessageRenderer -from strix.interface.utils import build_tui_stats_text +from strix.interface.utils import build_tui_stats_text, format_resume_hint from strix.orchestration.scan import run_strix_scan from strix.runtime import session_manager from strix.telemetry.tracer import Tracer, set_global_tracer @@ -1991,4 +1991,16 @@ class StrixTUIApp(App): # type: ignore[misc] async def run_tui(args: argparse.Namespace) -> None: """Run strix in interactive TUI mode with textual.""" app = StrixTUIApp(args) - await app.run_async() + try: + await app.run_async() + finally: + # After Textual restores the real terminal, print the resume + # hint so the user knows the exact ``strix --run-name X`` + # invocation that picks up where they left off. + from rich.console import Console + + hint = format_resume_hint(getattr(args, "run_name", None)) + if hint is not None: + console = Console() + console.print() + console.print(hint) diff --git a/strix/interface/utils.py b/strix/interface/utils.py index 1acd4a7..ddf95e3 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -1449,3 +1449,37 @@ def validate_config_file(config_path: str) -> Path: sys.exit(1) return path + + +def format_resume_hint(run_name: str | None) -> Panel | None: + """Tell the user how to resume the current run, if it persisted state. + + Returns a Rich :class:`Panel` ready to ``console.print`` when + ``strix_runs//bus.json`` exists (i.e. the scan registered + at least the root agent and has snapshot state to resume from). + Returns ``None`` when there's nothing to resume — e.g. the user hit + Ctrl+C before sandbox bring-up, or no run-name was assigned yet. + + Mirrors the auto-resume contract in ``orchestration/scan.py``: + re-running with the same ``--run-name`` picks up where this scan + left off; deleting ``strix_runs//`` forces a fresh start. + """ + if not run_name: + return None + run_dir = Path("strix_runs") / run_name + if not (run_dir / "bus.json").exists(): + return None + + text = Text() + text.append("Run paused. Resume any time with:\n\n", style="white") + text.append(f" strix --run-name {run_name}\n", style="bold #60a5fa") + text.append("\nOr delete ", style="dim") + text.append(f"strix_runs/{run_name}/", style="bold dim") + text.append(" to start fresh.", style="dim") + return Panel( + text, + title="[bold white]STRIX", + title_align="left", + border_style="#60a5fa", + padding=(1, 2), + )