feat(interface): show resume hint on user-initiated exit
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 <run_name>
The panel only appears when ``strix_runs/<run_name>/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) <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
+14
-2
@@ -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)
|
||||
|
||||
@@ -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/<run_name>/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/<run_name>/`` 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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user