From 5bb9fe896bc2e57b20041914846c4d7db93e7dbd Mon Sep 17 00:00:00 2001 From: oyasumi <121568595+kusonooyasumi@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:23:07 -0500 Subject: [PATCH] feat(tui): replace Textual with a Go/Bubble Tea interface (#941) --- .github/workflows/build-release.yml | 22 + .gitignore | 5 + .pre-commit-config.yaml | 1 + CONTRIBUTING.md | 17 + Makefile | 21 +- README.md | 2 +- docs/contributing.mdx | 17 + pyproject.toml | 28 +- scripts/build.sh | 14 + scripts/tui_sidecar_hook.py | 58 + strix.spec | 53 +- strix/config/codex.py | 16 +- strix/config/settings.py | 11 +- strix/core/inputs.py | 17 + strix/interface/assets/tui_styles.tcss | 697 ------ strix/interface/cli_args.py | 369 +++ strix/interface/environment.py | 217 ++ strix/interface/interactive.py | 38 + strix/interface/main.py | 832 +------ strix/interface/scan_setup.py | 223 ++ strix/interface/tui/__init__.py | 6 +- strix/interface/tui/app.py | 2100 ----------------- strix/interface/tui/backend/__init__.py | 7 + strix/interface/tui/backend/controller.py | 497 ++++ strix/interface/tui/backend/live_view.py | 136 ++ strix/interface/tui/backend/messages.py | 61 + strix/interface/tui/backend/projection.py | 182 ++ strix/interface/tui/backend/protocol.py | 40 + strix/interface/tui/backend/server.py | 531 +++++ strix/interface/tui/cmd/strix-tui/main.go | 35 + strix/interface/tui/go.mod | 32 + strix/interface/tui/go.sum | 61 + strix/interface/tui/internal/app/agents.go | 253 ++ strix/interface/tui/internal/app/client.go | 250 ++ .../interface/tui/internal/app/client_test.go | 284 +++ .../tui/internal/app/frame_bench_test.go | 98 + .../interface/tui/internal/app/input_test.go | 216 ++ strix/interface/tui/internal/app/model.go | 405 ++++ .../interface/tui/internal/app/model_test.go | 1171 +++++++++ strix/interface/tui/internal/app/selection.go | 284 +++ .../tui/internal/app/selection_test.go | 185 ++ strix/interface/tui/internal/app/setup.go | 523 ++++ .../tui/internal/app/setup_log_test.go | 95 + .../tui/internal/app/setup_prompt_test.go | 292 +++ strix/interface/tui/internal/app/update.go | 600 +++++ strix/interface/tui/internal/app/view.go | 782 ++++++ .../interface/tui/internal/app/vuln_report.go | 176 ++ .../tui/internal/app/vulnerabilities.go | 405 ++++ strix/interface/tui/internal/app/wire.go | 467 ++++ .../tui/internal/protocol/protocol.go | 118 + .../tui/internal/protocol/protocol_test.go | 22 + .../tui/internal/render/agent_message.go | 320 +++ .../tui/internal/render/agents_graph.go | 86 + strix/interface/tui/internal/render/chat.go | 32 + strix/interface/tui/internal/render/code.go | 70 + .../tui/internal/render/dependency.go | 99 + .../tui/internal/render/file_edit.go | 143 ++ .../interface/tui/internal/render/helpers.go | 112 + strix/interface/tui/internal/render/image.go | 106 + .../tui/internal/render/image_detect.go | 139 ++ .../tui/internal/render/image_detect_test.go | 133 ++ .../tui/internal/render/image_detect_unix.go | 37 + .../internal/render/image_detect_windows.go | 19 + .../tui/internal/render/image_kitty.go | 202 ++ .../tui/internal/render/image_kitty_test.go | 103 + .../tui/internal/render/markdown_test.go | 103 + strix/interface/tui/internal/render/notes.go | 111 + strix/interface/tui/internal/render/proxy.go | 577 +++++ .../interface/tui/internal/render/registry.go | 135 ++ .../tui/internal/render/render_test.go | 251 ++ strix/interface/tui/internal/render/report.go | 126 + .../tui/internal/render/report_list.go | 129 + .../interface/tui/internal/render/respond.go | 18 + strix/interface/tui/internal/render/scan.go | 31 + strix/interface/tui/internal/render/simple.go | 52 + strix/interface/tui/internal/render/styles.go | 82 + .../interface/tui/internal/render/terminal.go | 183 ++ strix/interface/tui/internal/render/todo.go | 80 + strix/interface/tui/live_view.py | 164 +- strix/interface/tui/messages.py | 43 - strix/interface/tui/renderers/__init__.py | 32 - .../tui/renderers/agent_message_renderer.py | 180 -- .../tui/renderers/agents_graph_renderer.py | 175 -- .../interface/tui/renderers/base_renderer.py | 30 - .../tui/renderers/filesystem_renderer.py | 266 --- .../tui/renderers/finish_renderer.py | 65 - .../tui/renderers/load_skill_renderer.py | 37 - .../interface/tui/renderers/notes_renderer.py | 180 -- .../interface/tui/renderers/proxy_renderer.py | 536 ----- strix/interface/tui/renderers/registry.py | 71 - .../tui/renderers/reporting_renderer.py | 547 ----- .../tui/renderers/respond_renderer.py | 35 - .../interface/tui/renderers/shell_renderer.py | 266 --- .../tui/renderers/thinking_renderer.py | 31 - .../interface/tui/renderers/todo_renderer.py | 225 -- .../tui/renderers/user_message_renderer.py | 29 - .../tui/renderers/web_search_renderer.py | 29 - strix/interface/tui/runtime.py | 392 +++ strix/interface/tui/sidecar.py | 191 ++ strix/interface/update_check.py | 25 +- strix/interface/utils.py | 60 +- strix/interface/viewer/auth.py | 6 +- .../live/tool-renderers/ViewImageRenderer.tsx | 35 +- .../viewer/static/assets/index-C3kQ5kk8.css | 10 - .../{index-CGvQq6oe.js => index-DBJ-RJqo.js} | 12 +- .../viewer/static/assets/index-DKbLYAbP.css | 10 + strix/interface/viewer/static/index.html | 4 +- strix/interface/viewer/transcript.py | 6 +- strix/runtime/session_manager.py | 8 +- strix/telemetry/_common.py | 6 + strix/telemetry/logging.py | 27 + strix/telemetry/posthog.py | 4 +- strix/telemetry/scarf.py | 4 +- strix/tools/web_search/tool.py | 6 +- tests/test_cli_target_list.py | 82 + tests/test_codex_auth.py | 1 + tests/test_cost_tracking.py | 7 +- tests/test_go_tui_runtime.py | 907 +++++++ tests/test_inputs.py | 35 +- tests/test_local_sources.py | 52 + tests/test_packaging.py | 31 + tests/test_proxy_renderer.py | 46 - tests/test_tui_backend_controller.py | 430 ++++ tests/test_tui_backend_server.py | 560 +++++ tests/test_tui_protocol_conformance.py | 49 + tests/test_tui_resume_history.py | 281 +++ tests/test_unraisable_filter.py | 53 + uv.lock | 59 - 128 files changed, 16229 insertions(+), 6562 deletions(-) create mode 100644 scripts/tui_sidecar_hook.py delete mode 100644 strix/interface/assets/tui_styles.tcss create mode 100644 strix/interface/cli_args.py create mode 100644 strix/interface/environment.py create mode 100644 strix/interface/interactive.py create mode 100644 strix/interface/scan_setup.py delete mode 100644 strix/interface/tui/app.py create mode 100644 strix/interface/tui/backend/__init__.py create mode 100644 strix/interface/tui/backend/controller.py create mode 100644 strix/interface/tui/backend/live_view.py create mode 100644 strix/interface/tui/backend/messages.py create mode 100644 strix/interface/tui/backend/projection.py create mode 100644 strix/interface/tui/backend/protocol.py create mode 100644 strix/interface/tui/backend/server.py create mode 100644 strix/interface/tui/cmd/strix-tui/main.go create mode 100644 strix/interface/tui/go.mod create mode 100644 strix/interface/tui/go.sum create mode 100644 strix/interface/tui/internal/app/agents.go create mode 100644 strix/interface/tui/internal/app/client.go create mode 100644 strix/interface/tui/internal/app/client_test.go create mode 100644 strix/interface/tui/internal/app/frame_bench_test.go create mode 100644 strix/interface/tui/internal/app/input_test.go create mode 100644 strix/interface/tui/internal/app/model.go create mode 100644 strix/interface/tui/internal/app/model_test.go create mode 100644 strix/interface/tui/internal/app/selection.go create mode 100644 strix/interface/tui/internal/app/selection_test.go create mode 100644 strix/interface/tui/internal/app/setup.go create mode 100644 strix/interface/tui/internal/app/setup_log_test.go create mode 100644 strix/interface/tui/internal/app/setup_prompt_test.go create mode 100644 strix/interface/tui/internal/app/update.go create mode 100644 strix/interface/tui/internal/app/view.go create mode 100644 strix/interface/tui/internal/app/vuln_report.go create mode 100644 strix/interface/tui/internal/app/vulnerabilities.go create mode 100644 strix/interface/tui/internal/app/wire.go create mode 100644 strix/interface/tui/internal/protocol/protocol.go create mode 100644 strix/interface/tui/internal/protocol/protocol_test.go create mode 100644 strix/interface/tui/internal/render/agent_message.go create mode 100644 strix/interface/tui/internal/render/agents_graph.go create mode 100644 strix/interface/tui/internal/render/chat.go create mode 100644 strix/interface/tui/internal/render/code.go create mode 100644 strix/interface/tui/internal/render/dependency.go create mode 100644 strix/interface/tui/internal/render/file_edit.go create mode 100644 strix/interface/tui/internal/render/helpers.go create mode 100644 strix/interface/tui/internal/render/image.go create mode 100644 strix/interface/tui/internal/render/image_detect.go create mode 100644 strix/interface/tui/internal/render/image_detect_test.go create mode 100644 strix/interface/tui/internal/render/image_detect_unix.go create mode 100644 strix/interface/tui/internal/render/image_detect_windows.go create mode 100644 strix/interface/tui/internal/render/image_kitty.go create mode 100644 strix/interface/tui/internal/render/image_kitty_test.go create mode 100644 strix/interface/tui/internal/render/markdown_test.go create mode 100644 strix/interface/tui/internal/render/notes.go create mode 100644 strix/interface/tui/internal/render/proxy.go create mode 100644 strix/interface/tui/internal/render/registry.go create mode 100644 strix/interface/tui/internal/render/render_test.go create mode 100644 strix/interface/tui/internal/render/report.go create mode 100644 strix/interface/tui/internal/render/report_list.go create mode 100644 strix/interface/tui/internal/render/respond.go create mode 100644 strix/interface/tui/internal/render/scan.go create mode 100644 strix/interface/tui/internal/render/simple.go create mode 100644 strix/interface/tui/internal/render/styles.go create mode 100644 strix/interface/tui/internal/render/terminal.go create mode 100644 strix/interface/tui/internal/render/todo.go delete mode 100644 strix/interface/tui/messages.py delete mode 100644 strix/interface/tui/renderers/__init__.py delete mode 100644 strix/interface/tui/renderers/agent_message_renderer.py delete mode 100644 strix/interface/tui/renderers/agents_graph_renderer.py delete mode 100644 strix/interface/tui/renderers/base_renderer.py delete mode 100644 strix/interface/tui/renderers/filesystem_renderer.py delete mode 100644 strix/interface/tui/renderers/finish_renderer.py delete mode 100644 strix/interface/tui/renderers/load_skill_renderer.py delete mode 100644 strix/interface/tui/renderers/notes_renderer.py delete mode 100644 strix/interface/tui/renderers/proxy_renderer.py delete mode 100644 strix/interface/tui/renderers/registry.py delete mode 100644 strix/interface/tui/renderers/reporting_renderer.py delete mode 100644 strix/interface/tui/renderers/respond_renderer.py delete mode 100644 strix/interface/tui/renderers/shell_renderer.py delete mode 100644 strix/interface/tui/renderers/thinking_renderer.py delete mode 100644 strix/interface/tui/renderers/todo_renderer.py delete mode 100644 strix/interface/tui/renderers/user_message_renderer.py delete mode 100644 strix/interface/tui/renderers/web_search_renderer.py create mode 100644 strix/interface/tui/runtime.py create mode 100644 strix/interface/tui/sidecar.py delete mode 100644 strix/interface/viewer/static/assets/index-C3kQ5kk8.css rename strix/interface/viewer/static/assets/{index-CGvQq6oe.js => index-DBJ-RJqo.js} (98%) create mode 100644 strix/interface/viewer/static/assets/index-DKbLYAbP.css create mode 100644 tests/test_go_tui_runtime.py create mode 100644 tests/test_packaging.py delete mode 100644 tests/test_proxy_renderer.py create mode 100644 tests/test_tui_backend_controller.py create mode 100644 tests/test_tui_backend_server.py create mode 100644 tests/test_tui_protocol_conformance.py create mode 100644 tests/test_tui_resume_history.py create mode 100644 tests/test_unraisable_filter.py diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 8de42570..9b1f267f 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -17,14 +17,19 @@ jobs: include: - os: macos-latest target: macos-arm64 + wheel-platform: macosx_11_0_arm64 - os: macos-15-intel target: macos-x86_64 + wheel-platform: macosx_11_0_x86_64 - os: ubuntu-22.04 target: linux-x86_64 + wheel-platform: manylinux_2_17_x86_64 - os: ubuntu-22.04-arm target: linux-arm64 + wheel-platform: manylinux_2_17_aarch64 - os: windows-latest target: windows-x86_64 + wheel-platform: win_amd64 runs-on: ${{ matrix.os }} @@ -39,17 +44,33 @@ jobs: - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version: '1.24.x' + check-latest: true + cache-dependency-path: strix/interface/tui/go.sum + - name: Build shell: bash + env: + STRIX_WHEEL_PLATFORM_TAG: ${{ matrix.wheel-platform }} run: | uv sync --frozen + uv build --wheel + uv run python -c 'import glob, os, sys, zipfile; wheels = glob.glob("dist/*.whl"); assert len(wheels) == 1, wheels; archive = zipfile.ZipFile(wheels[0]); tui = "strix/bin/strix-tui.exe" if sys.platform == "win32" else "strix/bin/strix-tui"; assert tui in archive.namelist(); metadata = archive.read(next(name for name in archive.namelist() if name.endswith(".dist-info/WHEEL"))).decode(); assert "Root-Is-Purelib: false" in metadata; assert "Tag: py3-none-" + os.environ["STRIX_WHEEL_PLATFORM_TAG"] in metadata' + uv run pyinstaller strix.spec --noconfirm if [[ "${{ runner.os }}" == "Windows" ]]; then + PYI_BINARY="dist/strix.exe" + TUI_NAME="strix-tui.exe" dist/strix.exe --version else + PYI_BINARY="dist/strix" + TUI_NAME="strix-tui" dist/strix --version fi + uv run pyi-archive_viewer -l "$PYI_BINARY" | grep "strix/bin/$TUI_NAME" >/dev/null if [[ "${{ matrix.target }}" == "linux-arm64" ]]; then file dist/strix @@ -77,6 +98,7 @@ jobs: path: | dist/release/*.tar.gz dist/release/*.zip + dist/*.whl if-no-files-found: error release: diff --git a/.gitignore b/.gitignore index cc938c29..89db2d7e 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,8 @@ Thumbs.db schema.graphql .opencode/ + +# Root-only local data and reference checkouts +/.benchmarks/ +/references/ +/strix_runs_main/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b201051b..f0a96b39 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,7 @@ repos: pydantic, fastapi, pytest, + hatchling, "openai-agents[litellm]==0.14.6", ] args: [--install-types, --non-interactive] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c7498c3..23028d3e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ Thank you for your interest in contributing to Strix! This guide will help you g ### Prerequisites - Python 3.12+ +- Latest Go 1.24.x patch (only for Bubble Tea TUI development and release artifacts) - Docker (running) - [uv](https://docs.astral.sh/uv/) (for dependency management) - Git @@ -113,6 +114,22 @@ make viewer # or: cd strix/interface/viewer/frontend && npm ci && npm run buil Commit both the source change and the regenerated `strix/interface/viewer/static/`. +## Package builds + +Editable installs do not need Go; they run the TUI from source (`go run`). + +Wheels always bundle the matching Go sidecar and are platform-specific: + +```bash +make wheel +``` + +The build hook (`scripts/tui_sidecar_hook.py`) compiles the sidecar, embeds it as +`strix/bin/strix-tui`, and assigns the current platform tag. It requires Go +1.24.x or newer and fails rather than producing a wheel without the sidecar. +`scripts/build.sh` and `strix.spec` are likewise strict for frozen PyInstaller +releases. + ## 🀝 Community - **Discord**: [Join our community](https://discord.gg/strix-ai) diff --git a/Makefile b/Makefile index da240367..05038f79 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev viewer +.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev viewer wheel tui-build tui-test tui-lint + +TUI_BINARY := build/sidecar/strix-tui$(if $(filter Windows_NT,$(OS)),.exe) help: @echo "Available commands:" @@ -16,7 +18,11 @@ help: @echo "Development:" @echo " pre-commit - Run pre-commit hooks on all files" @echo " viewer - Rebuild the local-viewer SPA (commit the output)" + @echo " wheel - Build a platform wheel with the bundled Go sidecar" @echo " clean - Clean up cache files and artifacts" + @echo " tui-build - Build the Bubble Tea TUI" + @echo " tui-test - Test the Bubble Tea TUI" + @echo " tui-lint - Vet and format-check the Bubble Tea TUI" install: uv sync --no-dev @@ -72,5 +78,18 @@ viewer: cd strix/interface/viewer/frontend && npm ci && npm run build @echo "βœ… Viewer built to strix/interface/viewer/static/ (commit the changes)." +wheel: + uv build --wheel + dev: format lint type-check @echo "βœ… Development cycle complete!" + +tui-build: + mkdir -p build/sidecar + cd strix/interface/tui && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ../../../$(TUI_BINARY) ./cmd/strix-tui + +tui-test: + cd strix/interface/tui && go test -race ./... + +tui-lint: + cd strix/interface/tui && test -z "$$(gofmt -l .)" && go vet ./... diff --git a/README.md b/README.md index 982a635d..a8dea067 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ Have questions? Found a bug? Want to contribute? **[Join our Discord!](https://d ## Acknowledgements -Strix builds on the incredible work of open-source projects like [LiteLLM](https://github.com/BerriAI/litellm), [Caido](https://github.com/caido/caido), [Nuclei](https://github.com/projectdiscovery/nuclei), [Playwright](https://github.com/microsoft/playwright), and [Textual](https://github.com/Textualize/textual). Huge thanks to their maintainers! +Strix builds on the incredible work of open-source projects like [LiteLLM](https://github.com/BerriAI/litellm), [Caido](https://github.com/caido/caido), [Nuclei](https://github.com/projectdiscovery/nuclei), [Playwright](https://github.com/microsoft/playwright), and [Bubble Tea](https://github.com/charmbracelet/bubbletea). Huge thanks to their maintainers! > [!WARNING] diff --git a/docs/contributing.mdx b/docs/contributing.mdx index 2d91529d..4d7f8fb2 100644 --- a/docs/contributing.mdx +++ b/docs/contributing.mdx @@ -8,6 +8,7 @@ description: "Contribute to Strix development" ### Prerequisites - Python 3.12+ +- Latest Go 1.24.x patch (only for Bubble Tea TUI development and release artifacts) - Docker (running) - [uv](https://docs.astral.sh/uv/) - Git @@ -74,6 +75,22 @@ Skills are specialized knowledge packages that enhance agent capabilities. They - Small, focused functions - Meaningful variable names +## Package Builds + +Editable installs do not require Go; they run the TUI from source (`go run`). + +Wheels are intentionally strict: they always bundle the matching Go sidecar and +are platform-specific. + +```bash +make wheel +``` + +The build hook (`scripts/tui_sidecar_hook.py`) requires Go 1.24.x or newer, embeds +the sidecar as `strix/bin/strix-tui`, and assigns the current platform tag. +Frozen releases built by `scripts/build.sh` and `strix.spec` also require the +sidecar. + ## Reporting Issues Include: diff --git a/pyproject.toml b/pyproject.toml index a24b6a12..0655cf68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,6 @@ dependencies = [ "pydantic-settings>=2.13.0", "rich", "docker>=7.1.0", - "textual>=6.0.0", "requests>=2.32.0", "cvss>=3.2", "caido-sdk-client>=0.2.0", @@ -82,7 +81,19 @@ packages = ["strix"] # The prebuilt viewer bundle under strix/interface/viewer/static/ ships automatically # (hatchling includes non-.py files under the package). The Vite SOURCE lives # under the package dir too (strix/interface/viewer/frontend/) but must never ship in the wheel. -exclude = ["strix/interface/viewer/frontend", "strix/interface/viewer/frontend/**"] +exclude = [ + "strix/interface/viewer/frontend", + "strix/interface/viewer/frontend/**", + # Go TUI SOURCE lives under the package dir but must never ship in the wheel; + # the compiled sidecar is force-included as strix/bin/strix-tui instead. + "strix/interface/tui/cmd/**", + "strix/interface/tui/internal/**", + "strix/interface/tui/go.mod", + "strix/interface/tui/go.sum", +] + +[tool.hatch.build.targets.wheel.hooks.custom] +path = "scripts/tui_sidecar_hook.py" # ============================================================================ # Type Checking Configuration @@ -115,7 +126,6 @@ module = [ "litellm.*", "rich.*", "jinja2.*", - "textual.*", "cvss.*", "docker.*", "caido_sdk_client.*", @@ -217,6 +227,8 @@ ignore = [ # args they intentionally ignore. "tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"] "tests/test_codex_auth.py" = ["S105", "S106", "SLF001"] +# Hatchling loads the build hook by path, not as an importable package. +"scripts/tui_sidecar_hook.py" = ["INP001"] # Stdlib HTTP handler overrides (do_GET/do_POST). "strix/interface/auth_cli.py" = ["N802"] "tests/test_codex_streaming.py" = ["N802"] @@ -275,9 +287,15 @@ ignore = [ # CLI / TUI / main keep extensive lazy imports + broad exception # swallows for resilience around terminal-rendering errors. "strix/interface/cli.py" = ["BLE001", "PLC0415"] -"strix/interface/tui/app.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915", "SIM105"] +"strix/interface/scan_setup.py" = ["PLC0415"] "strix/interface/main.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915"] -"strix/interface/tui/renderers/agent_message_renderer.py" = ["PLC0415"] +"strix/interface/cli_args.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915"] +"strix/interface/environment.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915"] +# The Go TUI runtime and backend controller import interface modules lazily so +# the sidecar entry point stays fast and avoids circular imports. +"strix/interface/interactive.py" = ["PLC0415"] +"strix/interface/tui/runtime.py" = ["PLC0415"] +"strix/interface/tui/backend/controller.py" = ["PLC0415"] [tool.ruff.lint.isort] force-single-line = false diff --git a/scripts/build.sh b/scripts/build.sh index 66fd066f..6a6c29e3 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -39,6 +39,12 @@ if ! command -v uv &> /dev/null; then exit 1 fi +if ! command -v go &> /dev/null; then + echo -e "${RED}Error: Go is not installed${NC}" + echo "Go 1.24 or newer is required to build the Bubble Tea TUI." + exit 1 +fi + echo -e "\n${BLUE}Installing dependencies...${NC}" uv sync --frozen @@ -48,6 +54,14 @@ echo -e "${YELLOW}Version:${NC} $VERSION" echo -e "\n${BLUE}Cleaning previous builds...${NC}" rm -rf build/ dist/ +echo -e "\n${BLUE}Building Bubble Tea sidecar...${NC}" +TUI_BINARY="build/sidecar/strix-tui" +if [ "$OS_NAME" = "windows" ]; then + TUI_BINARY="${TUI_BINARY}.exe" +fi +mkdir -p build/sidecar +(cd strix/interface/tui && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "../../../$TUI_BINARY" ./cmd/strix-tui) + echo -e "\n${BLUE}Building binary with PyInstaller...${NC}" uv run pyinstaller strix.spec --noconfirm diff --git a/scripts/tui_sidecar_hook.py b/scripts/tui_sidecar_hook.py new file mode 100644 index 00000000..800aa778 --- /dev/null +++ b/scripts/tui_sidecar_hook.py @@ -0,0 +1,58 @@ +"""Hatchling build hook that compiles and bundles the Go TUI sidecar.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sysconfig +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class CustomBuildHook(BuildHookInterface[Any]): + """Compile the Bubble Tea sidecar and ship it inside the wheel. + + The sidecar is the only interactive interface, so every wheel is a + platform wheel and a missing Go toolchain is a build failure. + """ + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + # Editable installs run from the checkout, where the TUI is started + # with ``go run``; there is nothing to bundle. + if version == "editable": + return + + root = Path(self.root) + executable = "strix-tui.exe" if os.name == "nt" else "strix-tui" + output = root / "build" / "sidecar" / executable + output.parent.mkdir(parents=True, exist_ok=True) + + go = shutil.which("go") + if go is None: + raise RuntimeError("Go 1.24 or newer is required to build the Bubble Tea TUI") + env = os.environ.copy() + env["CGO_ENABLED"] = "0" + subprocess.run( # noqa: S603 - fixed build command using the resolved Go binary + [ + go, + "build", + "-trimpath", + "-ldflags=-s -w", + "-o", + str(output), + "./cmd/strix-tui", + ], + cwd=root / "strix" / "interface" / "tui", + env=env, + check=True, + ) + + build_data["force_include"][str(output)] = f"strix/bin/{executable}" + build_data["pure_python"] = False + platform_tag = os.environ.get("STRIX_WHEEL_PLATFORM_TAG") + if not platform_tag: + platform_tag = sysconfig.get_platform().replace("-", "_").replace(".", "_") + build_data["tag"] = f"py3-none-{platform_tag}" diff --git a/strix.spec b/strix.spec index 19e39b37..827e5e2c 100644 --- a/strix.spec +++ b/strix.spec @@ -7,6 +7,14 @@ from PyInstaller.utils.hooks import collect_data_files, collect_submodules project_root = Path(SPECPATH) strix_root = project_root / 'strix' +tui_name = 'strix-tui.exe' if sys.platform == 'win32' else 'strix-tui' +tui_binary = project_root / 'build' / 'sidecar' / tui_name +if not tui_binary.is_file(): + raise FileNotFoundError( + f'Missing Go TUI sidecar at {tui_binary}; run `make tui-build` first' + ) +binaries = [(str(tui_binary), 'strix/bin')] + datas = [] for md_file in strix_root.rglob('skills/**/*.md'): @@ -21,10 +29,6 @@ for xml_file in strix_root.rglob('*.xml'): rel_path = xml_file.relative_to(project_root) datas.append((str(xml_file), str(rel_path.parent))) -for tcss_file in strix_root.rglob('*.tcss'): - rel_path = tcss_file.relative_to(project_root) - datas.append((str(tcss_file), str(rel_path.parent))) - # Prebuilt local-viewer SPA (served by `strix view`). viewer_static = strix_root / 'interface' / 'viewer' / 'static' for asset in viewer_static.rglob('*'): @@ -32,8 +36,6 @@ for asset in viewer_static.rglob('*'): rel_path = asset.relative_to(project_root) datas.append((str(asset), str(rel_path.parent))) -datas += collect_data_files('textual') - datas += collect_data_files('tiktoken') datas += collect_data_files('tiktoken_ext') @@ -52,17 +54,6 @@ hiddenimports = [ 'litellm.utils', 'litellm.caching', - # Textual TUI - 'textual', - 'textual.app', - 'textual.widgets', - 'textual.containers', - 'textual.screen', - 'textual.binding', - 'textual.reactive', - 'textual.css', - 'textual._text_area_theme', - # Rich console 'rich', 'rich.console', @@ -125,28 +116,21 @@ hiddenimports = [ 'strix.interface.main', 'strix.interface.cli', 'strix.interface.tui', - 'strix.interface.tui.app', + 'strix.interface.tui.runtime', 'strix.interface.tui.history', 'strix.interface.tui.live_view', - 'strix.interface.tui.messages', - 'strix.interface.tui.renderers', - 'strix.interface.tui.renderers.agent_message_renderer', - 'strix.interface.tui.renderers.agents_graph_renderer', - 'strix.interface.tui.renderers.base_renderer', - 'strix.interface.tui.renderers.finish_renderer', - 'strix.interface.tui.renderers.notes_renderer', - 'strix.interface.tui.renderers.proxy_renderer', - 'strix.interface.tui.renderers.registry', - 'strix.interface.tui.renderers.reporting_renderer', - 'strix.interface.tui.renderers.thinking_renderer', - 'strix.interface.tui.renderers.todo_renderer', - 'strix.interface.tui.renderers.user_message_renderer', - 'strix.interface.tui.renderers.web_search_renderer', + 'strix.interface.tui.backend', + 'strix.interface.tui.backend.controller', + 'strix.interface.tui.backend.messages', + 'strix.interface.tui.backend.protocol', + 'strix.interface.tui.backend.server', 'strix.interface.utils', 'strix.agents', 'strix.agents.factory', 'strix.agents.prompt', - 'strix.config.models', + 'strix.config.loader', + 'strix.config.settings', + 'strix.config.codex', 'strix.core', 'strix.core.agents', 'strix.core.execution', @@ -196,7 +180,6 @@ hiddenimports = [ ] hiddenimports += collect_submodules('litellm') -hiddenimports += collect_submodules('textual') hiddenimports += collect_submodules('rich') hiddenimports += collect_submodules('pydantic') hiddenimports += collect_submodules('pygments') @@ -263,7 +246,7 @@ excludes = [ a = Analysis( ['strix/interface/main.py'], pathex=[str(project_root)], - binaries=[], + binaries=binaries, datas=datas, hiddenimports=hiddenimports, hookspath=[], diff --git a/strix/config/codex.py b/strix/config/codex.py index dcd277a3..94bc8470 100644 --- a/strix/config/codex.py +++ b/strix/config/codex.py @@ -221,19 +221,23 @@ def _first(query: dict[str, list[str]], key: str) -> str | None: def _post_form(payload: dict[str, str]) -> dict[str, Any]: + detail = "" try: - response = requests.post( + with requests.post( TOKEN_URL, data=payload, headers={"Accept": "application/json"}, timeout=_TOKEN_TIMEOUT, - ) + ) as response: + status_code = response.status_code + body = response.content + if status_code >= 400: + detail = response.text[:300] except requests.RequestException as exc: raise CodexAuthError("unavailable", str(exc)) from exc - if response.status_code >= 400: - detail = response.text[:300] - raise CodexAuthError("token_http_error", f"HTTP {response.status_code}: {detail}") - data = json.loads(response.content or b"{}") + if status_code >= 400: + raise CodexAuthError("token_http_error", f"HTTP {status_code}: {detail}") + data = json.loads(body or b"{}") if not isinstance(data, dict): raise CodexAuthError("bad_response", "token endpoint returned non-object") return data diff --git a/strix/config/settings.py b/strix/config/settings.py index ec9c1e10..3dc941d9 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -26,6 +26,7 @@ class LlmSettings(BaseSettings): api_key: str | None = Field( default=None, validation_alias=AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"), + repr=False, ) api_base: str | None = Field( default=None, @@ -40,6 +41,7 @@ class LlmSettings(BaseSettings): extra_headers: dict[str, str] | None = Field( default=None, alias="LLM_EXTRA_HEADERS", + repr=False, ) reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT") force_required_tool_choice: bool = Field( @@ -65,11 +67,12 @@ class DedupeSettings(BaseSettings): default=None, alias="STRIX_DEDUPE_REASONING_EFFORT", ) - api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY") + api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY", repr=False) api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE") extra_headers: dict[str, str] | None = Field( default=None, alias="DEDUPE_LLM_EXTRA_HEADERS", + repr=False, ) @@ -114,7 +117,11 @@ class TelemetrySettings(BaseSettings): class IntegrationSettings(BaseSettings): model_config = _BASE_CONFIG - perplexity_api_key: str | None = Field(default=None, alias="PERPLEXITY_API_KEY") + perplexity_api_key: str | None = Field( + default=None, + alias="PERPLEXITY_API_KEY", + repr=False, + ) class ViewerSettings(BaseSettings): diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 328ad076..1cb65533 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -75,6 +75,23 @@ def build_root_task(scan_config: dict[str, Any]) -> str: parts.append(f"\n\n{label}:") parts.extend(items) + # A workspace mount is a directory to work in, not an asset to test. It is + # listed apart from the targets so it never reads as scope. + if workspace_mount := scan_config.get("workspace_mount") or "": + subdir = scan_config.get("workspace_subdir") or "" + workspace_path = f"/workspace/{subdir}" if subdir else "/workspace" + parts.append("\n\nWorking Directory:") + parts.append( + f"- {workspace_mount} (available at: {workspace_path}; " + "this is the user's real directory, mounted live and writable β€” " + ".git/.agents/.codex are read-only)" + ) + parts.append( + "- No scan target was set. This directory is where you work, not a " + "target to assess: the instructions below are the only source of " + "truth for what to do." + ) + if diff_scope.get("active"): parts.append("\n\nScope Constraints:") parts.append( diff --git a/strix/interface/assets/tui_styles.tcss b/strix/interface/assets/tui_styles.tcss deleted file mode 100644 index 7e964dfa..00000000 --- a/strix/interface/assets/tui_styles.tcss +++ /dev/null @@ -1,697 +0,0 @@ -Screen { - background: #000000; - color: #d4d4d4; -} - -.screen--selection { - background: #2d3d2f; - color: #e5e5e5; -} - -ToastRack { - dock: top; - align: right top; - margin-bottom: 0; - margin-top: 1; -} - -Toast { - width: 25; - background: #000000; - border-left: outer #22c55e; -} - -Toast.-information .toast--title { - color: #22c55e; -} - -#splash_screen { - height: 100%; - width: 100%; - background: #000000; - color: #22c55e; - align: center middle; - content-align: center middle; - text-align: center; -} - -#splash_content { - width: auto; - height: auto; - background: transparent; - text-align: center; - content-align: center middle; - padding: 2; -} - -#main_container { - height: 100%; - padding: 0; - margin: 0; - background: #000000; -} - -#content_container { - height: 1fr; - padding: 0; - background: transparent; -} - -#sidebar { - width: 20%; - background: transparent; - margin-left: 1; -} - -#sidebar.-hidden { - display: none; -} - -#viewer_cta { - height: auto; - background: transparent; - border: round #333333; - color: #60a5fa; - padding: 0 1; - margin-bottom: 1; - text-align: center; -} - -#agents_tree { - height: 1fr; - background: transparent; - border: round #333333; - border-title-color: #a8a29e; - border-title-style: bold; - padding: 1; - margin-bottom: 0; -} - -#stats_scroll { - height: auto; - max-height: 15; - background: transparent; - padding: 0; - margin: 0; - border: round #333333; - scrollbar-size: 0 0; -} - -#stats_display { - height: auto; - background: transparent; - padding: 0 1; - margin: 0; -} - -#vulnerabilities_panel { - height: auto; - max-height: 12; - background: transparent; - padding: 0; - margin: 0; - border: round #333333; - overflow-y: auto; - scrollbar-background: #000000; - scrollbar-color: #333333; - scrollbar-corner-color: #000000; - scrollbar-size-vertical: 1; -} - -#vulnerabilities_panel.hidden { - display: none; -} - -.vuln-item { - height: auto; - width: 100%; - padding: 0 1; - background: transparent; - color: #d4d4d4; -} - -.vuln-item:hover { - background: #1a1a1a; - color: #fafaf9; -} - -VulnerabilityDetailScreen { - align: center middle; - background: #000000 80%; -} - -#vuln_detail_dialog { - grid-size: 1; - grid-gutter: 1; - grid-rows: 1fr auto; - padding: 2 3; - width: 85%; - max-width: 110; - height: 85%; - max-height: 45; - border: solid #262626; - background: #0a0a0a; -} - -#vuln_detail_scroll { - height: 1fr; - background: transparent; - scrollbar-background: #0a0a0a; - scrollbar-color: #404040; - scrollbar-corner-color: #0a0a0a; - scrollbar-size: 1 1; - padding-right: 1; -} - -#vuln_detail_content { - width: 100%; - background: transparent; - padding: 0; -} - -#vuln_detail_buttons { - width: 100%; - height: auto; - align: right middle; - padding-top: 1; - margin: 0; - border-top: solid #1a1a1a; -} - -#copy_vuln_detail { - width: auto; - min-width: 12; - height: auto; - background: transparent; - color: #525252; - border: none; - text-style: none; - margin: 0 1; - padding: 0 2; -} - -#close_vuln_detail { - width: auto; - min-width: 10; - height: auto; - background: transparent; - color: #a3a3a3; - border: none; - text-style: none; - margin: 0; - padding: 0 2; -} - -#copy_vuln_detail:hover, #copy_vuln_detail:focus { - background: transparent; - color: #22c55e; - border: none; -} - -#close_vuln_detail:hover, #close_vuln_detail:focus { - background: transparent; - color: #ffffff; - border: none; -} - -#chat_area_container { - width: 80%; - background: transparent; -} - -#chat_area_container.-full-width { - width: 100%; -} - -#chat_history { - height: 1fr; - background: transparent; - border: round #0a0a0a; - padding: 0; - margin-bottom: 0; - margin-right: 0; - scrollbar-background: #000000; - scrollbar-color: #1a1a1a; - scrollbar-corner-color: #000000; - scrollbar-size: 1 1; -} - -#agent_status_display { - height: 1; - background: transparent; - margin: 0; - padding: 0 1; -} - -#agent_status_display.hidden { - display: none; -} - -#status_text { - width: 1fr; - height: 100%; - background: transparent; - color: #a3a3a3; - text-align: left; - content-align: left middle; - text-style: none; - margin: 0; - padding: 0; -} - -#keymap_indicator { - width: auto; - height: 100%; - background: transparent; - color: #737373; - text-align: right; - content-align: right middle; - text-style: none; - margin: 0; - padding: 0; -} - -#chat_input_container { - height: 3; - background: transparent; - border: round #333333; - margin-right: 0; - padding: 0; - layout: horizontal; - align-vertical: top; -} - -#chat_input_container:focus-within { - border: round #22c55e; -} - -#chat_input_container:focus-within #chat_prompt { - color: #22c55e; - text-style: bold; -} - -#chat_prompt { - width: auto; - height: 100%; - padding: 0 0 0 1; - color: #737373; - content-align-vertical: top; -} - -#chat_history:focus { - border: round #22c55e; -} - -#chat_input { - width: 1fr; - height: 100%; - background: transparent; - border: none; - color: #d4d4d4; - padding: 0; - margin: 0; -} - -#chat_input:focus { - border: none; -} - -#chat_input .text-area--cursor-line { - background: transparent; -} - -#chat_input:focus .text-area--cursor-line { - background: transparent; -} - -#chat_input > .text-area--placeholder { - color: #525252; - text-style: italic; -} - -#chat_input > .text-area--cursor { - color: #22c55e; - background: #22c55e; -} - -.chat-placeholder { - width: 100%; - height: 100%; - content-align: center middle; - text-align: center; - color: #737373; - text-style: italic; -} - -.chat-content { - margin: 0 !important; - margin-top: 0 !important; - margin-bottom: 0 !important; - padding: 0 1; - background: transparent; - width: 100%; -} - -.chat-message { - margin-bottom: 0; - padding: 0; - background: transparent; - width: 100%; -} - -.user-message { - color: #e5e5e5; - border-left: thick #3b82f6; - padding-left: 1; - margin-bottom: 1; -} - -.tool-call { - margin-top: 1; - margin-bottom: 0; - padding: 0 1; - background: transparent; - border: none; - width: 100%; -} - -.tool-call.status-completed { - background: transparent; - margin-top: 1; - margin-bottom: 0; -} - -.tool-call.status-running { - background: transparent; - margin-top: 1; - margin-bottom: 0; -} - -.tool-call.status-failed, -.tool-call.status-error { - background: transparent; - margin-top: 1; - margin-bottom: 0; -} - -.browser-tool, -.terminal-tool, -.agents-graph-tool, -.file-edit-tool, -.proxy-tool, -.notes-tool, -.thinking-tool, -.web-search-tool, -.scan-info-tool, -.subagent-info-tool { - margin-top: 1; - margin-bottom: 0; - background: transparent; -} - -.finish-tool, -.reporting-tool { - margin-top: 1; - margin-bottom: 0; - background: transparent; -} - -.browser-tool.status-completed, -.browser-tool.status-running, -.terminal-tool.status-completed, -.terminal-tool.status-running, -.agents-graph-tool.status-completed, -.agents-graph-tool.status-running, -.file-edit-tool.status-completed, -.file-edit-tool.status-running, -.proxy-tool.status-completed, -.proxy-tool.status-running, -.notes-tool.status-completed, -.notes-tool.status-running, -.thinking-tool.status-completed, -.thinking-tool.status-running, -.web-search-tool.status-completed, -.web-search-tool.status-running, -.scan-info-tool.status-completed, -.scan-info-tool.status-running, -.subagent-info-tool.status-completed, -.subagent-info-tool.status-running { - background: transparent; - margin-top: 1; - margin-bottom: 0; -} - -.finish-tool.status-completed, -.finish-tool.status-running, -.reporting-tool.status-completed, -.reporting-tool.status-running { - background: transparent; - margin-top: 1; - margin-bottom: 0; -} - -Tree { - background: transparent; - color: #e7e5e4; - scrollbar-background: transparent; - scrollbar-color: #404040; - scrollbar-corner-color: transparent; - scrollbar-size: 1 1; -} - -Tree > .tree--label { - text-style: bold; - color: #a8a29e; - background: transparent; - padding: 0 1; - margin-bottom: 1; - border-bottom: solid #1a1a1a; - text-align: center; -} - -.tree--node { - height: 1; - padding: 0; - margin: 0; -} - -.tree--node-label { - color: #d6d3d1; - background: transparent; - text-style: none; - padding: 0 1; - margin: 0 1; -} - -.tree--node:hover .tree--node-label { - background: transparent; - color: #fafaf9; - text-style: bold; - border-left: solid #a8a29e; -} - -.tree--node.-selected .tree--node-label { - background: transparent; - color: #fafaf9; - text-style: bold; - border-left: heavy #d6d3d1; -} - -.tree--node.-expanded .tree--node-label { - text-style: bold; - color: #fafaf9; - background: transparent; - border-left: solid #78716c; -} - -Tree:focus { - border: round #1a1a1a; -} - -Tree:focus > .tree--label { - color: #fafaf9; - text-style: bold; - background: transparent; -} - -.tree--node .tree--node .tree--node-label { - color: #a8a29e; - padding-left: 2; - border: none; - background: transparent; - margin-left: 1; -} - -.tree--node .tree--node:hover .tree--node-label { - background: transparent; - color: #e7e5e4; -} - -.tree--node .tree--node .tree--node .tree--node-label { - color: #78716c; - padding-left: 3; - text-style: none; - border: none; - background: transparent; - margin-left: 2; -} - -StopAgentScreen { - align: center middle; - background: $background 0%; -} - -#stop_agent_dialog { - grid-size: 1; - grid-gutter: 1; - grid-rows: auto auto; - padding: 1; - width: 30; - height: auto; - border: round #a3a3a3; - background: #000000 98%; -} - -#stop_agent_title { - color: #a3a3a3; - text-style: bold; - text-align: center; - width: 100%; - margin-bottom: 0; -} - -#stop_agent_buttons { - grid-size: 2; - grid-gutter: 1; - grid-columns: 1fr 1fr; - width: 100%; - height: 1; -} - -#stop_agent_buttons Button { - height: 1; - min-height: 1; - border: none; - text-style: bold; -} - -#stop_agent { - background: transparent; - color: #ef4444; - border: none; -} - -#stop_agent:hover, #stop_agent:focus { - background: #ef4444; - color: #ffffff; - border: none; -} - -#cancel_stop { - background: transparent; - color: #737373; - border: none; -} - -#cancel_stop:hover, #cancel_stop:focus { - background:rgb(54, 54, 54); - color: #ffffff; - border: none; -} - -QuitScreen { - align: center middle; - background: $background 0%; -} - -#quit_dialog { - grid-size: 1; - grid-gutter: 1; - grid-rows: auto auto; - padding: 1; - width: 24; - height: auto; - border: round #333333; - background: #000000 98%; -} - -#quit_title { - color: #d4d4d4; - text-style: bold; - text-align: center; - width: 100%; - margin-bottom: 0; -} - -#quit_buttons { - grid-size: 2; - grid-gutter: 1; - grid-columns: 1fr 1fr; - width: 100%; - height: 1; -} - -#quit_buttons Button { - height: 1; - min-height: 1; - border: none; - text-style: bold; -} - -#quit { - background: transparent; - color: #ef4444; - border: none; -} - -#quit:hover, #quit:focus { - background: #ef4444; - color: #ffffff; - border: none; -} - -#cancel { - background: transparent; - color: #737373; - border: none; -} - -#cancel:hover, #cancel:focus { - background:rgb(54, 54, 54); - color: #ffffff; - border: none; -} - -HelpScreen { - align: center middle; - background: $background 0%; -} - -#dialog { - grid-size: 1; - grid-gutter: 0 1; - grid-rows: auto auto; - padding: 1 2; - width: 40; - height: auto; - border: round #22c55e; - background: #000000 98%; -} - -#help_title { - color: #22c55e; - text-style: bold; - text-align: center; - width: 100%; - margin-bottom: 1; -} - -#help_content { - color: #d4d4d4; - text-align: left; - width: 100%; - margin-bottom: 1; - padding: 0; - background: transparent; - text-style: none; -} diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py new file mode 100644 index 00000000..dce62d2c --- /dev/null +++ b/strix/interface/cli_args.py @@ -0,0 +1,369 @@ +"""Command-line argument parsing for the ``strix`` scan entrypoint.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from strix.config import apply_config_override +from strix.config.settings import DEFAULT_MAX_TURNS +from strix.core.paths import run_dir_for, runtime_state_dir +from strix.interface.scan_setup import attach_workspace_mount, build_targets_info +from strix.interface.update_check import self_update +from strix.interface.utils import ( + check_mountable_dir, + collect_local_sources, + validate_config_file, +) + + +def get_version() -> str: + try: + from importlib.metadata import version + + return version("strix-agent") + except Exception: + return "unknown" + + +def _positive_budget(value: str) -> float: + try: + budget = float(value) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid float value: {value!r}") from exc + import math + + if not math.isfinite(budget) or budget <= 0: + raise argparse.ArgumentTypeError("must be a finite number greater than 0") + return budget + + +def _positive_int(value: str) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") from exc + if parsed <= 0: + raise argparse.ArgumentTypeError("must be an integer greater than 0") + return parsed + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Strix Multi-Agent Cybersecurity Penetration Testing Tool", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Web application penetration test + strix --target https://example.com + + # GitHub repository analysis + strix --target https://github.com/user/repo + strix --target git@github.com:user/repo.git + + # Local code analysis + strix --target ./my-project + + # Domain penetration test + strix --target example.com + + # IP address penetration test + strix --target 192.168.1.42 + + # Multiple targets (e.g., white-box testing with source and deployed app) + strix --target https://github.com/user/repo --target https://example.com + strix --target ./my-project --target https://staging.example.com --target https://prod.example.com + + # Targets from a file, one target per non-empty, non-comment line + strix --target-list ./targets.txt + + # Custom instructions (inline) + strix --target example.com --instruction "Focus on authentication vulnerabilities" + + # Custom instructions (from file) + strix --target example.com --instruction-file ./instructions.txt + strix --target https://app.com --instruction-file /path/to/detailed_instructions.md + """, + ) + + parser.add_argument( + "-v", + "--version", + action="version", + version=f"strix {get_version()}", + ) + + parser.add_argument( + "--update", + action="store_true", + help="Update strix to the latest version and exit. Self-updates the " + "standalone binary install; for pip/pipx/uv installs, prints the " + "matching upgrade command instead.", + ) + + parser.add_argument( + "-t", + "--target", + type=str, + action="append", + help="Target to test (URL, repository, local directory path, domain name, or IP address). " + "Local directories are mounted into the sandbox writable. " + "Can be specified multiple times for multi-target scans. " + "Fresh runs require --target or --target-list.", + ) + parser.add_argument( + "--target-list", + type=str, + action="append", + metavar="PATH", + help="Path to a file containing targets, one per non-empty, non-comment line. " + "Can be specified multiple times and combined with --target.", + ) + parser.add_argument( + "--instruction", + type=str, + help="Custom instructions for the penetration test. This can be " + "specific vulnerability types to focus on (e.g., 'Focus on IDOR and XSS'), " + "testing approaches (e.g., 'Perform thorough authentication testing'), " + "test credentials (e.g., 'Use the following credentials to access the app: " + "admin:password123'), " + "or areas of interest (e.g., 'Check login API endpoint for security issues').", + ) + + parser.add_argument( + "--instruction-file", + type=str, + help="Path to a file containing detailed custom instructions for the penetration test. " + "Use this option when you have lengthy or complex instructions saved in a file " + "(e.g., '--instruction-file ./detailed_instructions.txt').", + ) + + parser.add_argument( + "-n", + "--non-interactive", + action="store_true", + help=( + "Run in non-interactive mode (no TUI, exits on completion). " + "Default is interactive mode with TUI." + ), + ) + + parser.add_argument( + "-m", + "--scan-mode", + type=str, + choices=["quick", "standard", "deep"], + default="deep", + help=( + "Scan mode: " + "'quick' for fast CI/CD checks, " + "'standard' for routine testing, " + "'deep' for thorough security reviews (default). " + "Default: deep." + ), + ) + + parser.add_argument( + "--scope-mode", + type=str, + choices=["auto", "diff", "full"], + default="auto", + help=( + "Scope mode for code targets: " + "'auto' enables PR diff-scope in CI/headless runs, " + "'diff' forces changed-files scope, " + "'full' disables diff-scope." + ), + ) + + parser.add_argument( + "--diff-base", + type=str, + help=( + "Target branch or commit to compare against (e.g., origin/main). " + "Defaults to the repository's default branch." + ), + ) + + parser.add_argument( + "--config", + type=str, + help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json", + ) + + parser.add_argument( + "--max-budget", + "--max-budget-usd", + dest="max_budget_usd", + metavar="USD", + type=_positive_budget, + default=None, + help=( + "Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached. " + "Graduated wrap-up warnings are sent to all agents as it is approached." + ), + ) + + parser.add_argument( + "--max-turns", + dest="max_turns", + metavar="N", + type=_positive_int, + default=DEFAULT_MAX_TURNS, + help=( + "Maximum turns per agent (> 0, default %(default)s). Each agent is force-stopped " + "when it reaches this limit, with graduated wrap-up warnings as it is approached." + ), + ) + + parser.add_argument( + "--resume", + type=str, + metavar="RUN_NAME", + help=( + "Resume a prior scan by its run name (the dir under ./strix_runs/). " + "Picks up the root + every non-terminal subagent's full LLM history " + "and agent topology. Skips fresh run-name generation." + ), + ) + + args = parser.parse_args() + # Startup-resolved state lives alongside the parsed flags. The full schema + # is established here so downstream code reads attributes directly. + args.needs_setup = False + args.targets_info = [] + args.local_sources = [] + args.diff_scope = {"active": False} + args.run_name = None + + if args.config: + apply_config_override(validate_config_file(args.config)) + + if args.update: + sys.exit(0 if self_update() else 1) + + if args.instruction and args.instruction_file: + parser.error( + "Cannot specify both --instruction and --instruction-file. Use one or the other." + ) + + if args.instruction_file: + instruction_path = Path(args.instruction_file) + try: + with instruction_path.open(encoding="utf-8") as f: + args.instruction = f.read().strip() + if not args.instruction: + parser.error(f"Instruction file '{instruction_path}' is empty") + except Exception as e: + parser.error(f"Failed to read instruction file '{instruction_path}': {e}") + + args.user_explicit_instruction = args.instruction if args.resume else None + # What the user actually asked for, kept apart from args.instruction because + # prepare_run prepends the diff-scope preamble to that. This is the text the + # transcript shows as their opening message. + args.user_instruction = args.instruction or None + + if args.resume: + if args.target or args.target_list: + parser.error( + "Cannot combine --resume with --target/--target-list. " + "--resume picks up where the prior run left off, including the " + "original target list." + ) + _load_resume_state(args, parser) + agents_path = runtime_state_dir(run_dir_for(args.resume)) / "agents.json" + if not agents_path.exists(): + parser.error( + f"--resume {args.resume}: missing {agents_path}. The run was " + f"persisted but never reached its first agent snapshot β€” " + f"there's nothing to resume from. Pick a fresh --run-name " + f"or remove --resume to start over with the same targets." + ) + else: + if not args.target and not args.target_list: + if args.non_interactive: + parser.error( + "the following arguments are required: -t/--target or --target-list " + "(or use --resume to continue a prior scan)" + ) + # Interactive launch with no target: open the normal TUI on its + # start screen, where the user gives a target or a bare prompt + # before the scan starts. + args.needs_setup = True + return args + + try: + build_targets_info(args) + except ValueError as e: + parser.error(str(e)) + + return args + + +def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None: + """Populate ``args.targets_info`` and friends from a prior run's run.json.""" + from strix.report.writer import read_run_record + + run_dir = run_dir_for(args.resume) + state_path = run_dir / "run.json" + if not state_path.exists(): + parser.error( + f"--resume {args.resume}: no such run " + f"(missing {state_path}; remove --resume for a fresh start)" + ) + try: + state = read_run_record(run_dir) + except RuntimeError as exc: + parser.error(f"--resume {args.resume}: run.json unreadable: {exc}") + + args.targets_info = state.get("targets_info") or [] + # A target-less run has no targets_info at all: it works in a mounted + # directory, driven by its instruction. + workspace_mount = state.get("workspace_mount") or None + if not args.targets_info and not workspace_mount: + parser.error(f"--resume {args.resume}: run.json has no targets_info") + + for target in args.targets_info: + if not isinstance(target, dict): + continue + details = target.get("details") or {} + if target.get("type") == "local_code" and details.get("target_path"): + try: + check_mountable_dir(Path(details["target_path"]).expanduser()) + except ValueError as exc: + parser.error(f"--resume {args.resume}: {exc}") + continue + if target.get("type") != "repository": + continue + cloned = details.get("cloned_repo_path") + if not cloned: + continue + if not Path(cloned).expanduser().exists(): + parser.error( + f"--resume {args.resume}: cloned repo at {cloned} is missing. " + f"It was deleted between runs. Pick a fresh --run-name to " + f"re-clone, or restore the directory before resuming." + ) + + if args.instruction is None: + args.instruction = state.get("instruction") + if not getattr(args, "user_instruction", None): + args.user_instruction = state.get("user_instruction") or None + args.local_sources = collect_local_sources(args.targets_info) + # Remount the workspace the run was started with. The user already confirmed + # this directory, so the target mount guard does not apply to it; it only has + # to still be there. + args.workspace_mount = workspace_mount + if workspace_mount: + if not Path(workspace_mount).expanduser().is_dir(): + parser.error( + f"--resume {args.resume}: the working directory {workspace_mount} " + f"is missing. Restore it before resuming, or start a fresh run." + ) + attach_workspace_mount(args) + if state.get("diff_scope"): + args.diff_scope = state.get("diff_scope") + persisted_scan_mode = state.get("scan_mode") + if persisted_scan_mode and args.scan_mode == "deep": + args.scan_mode = persisted_scan_mode diff --git a/strix/interface/environment.py b/strix/interface/environment.py new file mode 100644 index 00000000..5589a1fe --- /dev/null +++ b/strix/interface/environment.py @@ -0,0 +1,217 @@ +"""Startup environment validation and Docker image management.""" + +import logging +import shutil +import sys + +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +from strix.config import codex, load_settings +from strix.interface.utils import ( + check_docker_connection, + image_exists, + process_pull_line, +) + + +logger = logging.getLogger(__name__) + + +def validate_environment() -> None: + logger.info("Validating environment") + console = Console() + missing_required_vars = [] + missing_optional_vars = [] + + settings = load_settings() + + if codex.subscription_model(settings.llm.model): + if not codex.is_authenticated(): + console.print( + f"[red]STRIX_LLM={settings.llm.model} uses your ChatGPT subscription, " + "but you're not signed in.[/] Run [cyan]strix auth login chatgpt[/] first." + ) + sys.exit(1) + logger.info("Environment OK (ChatGPT subscription)") + return + + if not settings.llm.model: + missing_required_vars.append("STRIX_LLM") + + if not settings.llm.api_key: + missing_optional_vars.append("LLM_API_KEY") + + if not settings.llm.api_base: + missing_optional_vars.append("LLM_API_BASE") + + if not settings.integrations.perplexity_api_key: + missing_optional_vars.append("PERPLEXITY_API_KEY") + + if missing_required_vars: + error_text = Text() + error_text.append("MISSING REQUIRED ENVIRONMENT VARIABLES", style="bold red") + error_text.append("\n\n", style="white") + + for var in missing_required_vars: + error_text.append(f"β€’ {var}", style="bold yellow") + error_text.append(" is not set\n", style="white") + + if missing_optional_vars: + error_text.append("\nOptional environment variables:\n", style="dim white") + for var in missing_optional_vars: + error_text.append(f"β€’ {var}", style="dim yellow") + error_text.append(" is not set\n", style="dim white") + + error_text.append("\nRequired environment variables:\n", style="white") + for var in missing_required_vars: + if var == "STRIX_LLM": + error_text.append("β€’ ", style="white") + error_text.append("STRIX_LLM", style="bold cyan") + error_text.append( + " - Model name to use (e.g., 'openai/gpt-5.4' or " + "'anthropic/claude-opus-4-7')\n", + style="white", + ) + + if missing_optional_vars: + error_text.append("\nOptional environment variables:\n", style="white") + for var in missing_optional_vars: + if var == "LLM_API_BASE": + error_text.append("β€’ ", style="white") + error_text.append("LLM_API_BASE", style="bold cyan") + error_text.append( + " - Custom API base URL if using local models (e.g., Ollama, LMStudio)\n", + style="white", + ) + elif var == "PERPLEXITY_API_KEY": + error_text.append("β€’ ", style="white") + error_text.append("PERPLEXITY_API_KEY", style="bold cyan") + error_text.append( + " - API key for Perplexity AI web search (enables real-time research)\n", + style="white", + ) + elif var == "STRIX_REASONING_EFFORT": + error_text.append("β€’ ", style="white") + error_text.append("STRIX_REASONING_EFFORT", style="bold cyan") + error_text.append( + " - Reasoning effort level: none, minimal, low, medium, high, xhigh, " + "max (default: high)\n", + style="white", + ) + + error_text.append("\nExample setup:\n", style="white") + error_text.append("export STRIX_LLM='openai/gpt-5.4'\n", style="dim white") + + if missing_optional_vars: + for var in missing_optional_vars: + if var == "LLM_API_BASE": + error_text.append( + "export LLM_API_BASE='http://localhost:11434' " + "# needed for local models only\n", + style="dim white", + ) + elif var == "PERPLEXITY_API_KEY": + error_text.append( + "export PERPLEXITY_API_KEY='your-perplexity-key-here'\n", style="dim white" + ) + elif var == "STRIX_REASONING_EFFORT": + error_text.append( + "export STRIX_REASONING_EFFORT='high'\n", + style="dim white", + ) + + panel = Panel( + error_text, + title="[bold white]STRIX", + title_align="left", + border_style="red", + padding=(1, 2), + ) + + logger.debug("Missing required env vars: %s", missing_required_vars) + console.print("\n") + console.print(panel) + console.print() + sys.exit(1) + logger.info( + "Environment OK (optional missing: %s)", + missing_optional_vars or "none", + ) + + +def check_docker_installed() -> None: + if shutil.which("docker") is None: + logger.debug("Docker CLI not found in PATH") + console = Console() + error_text = Text() + error_text.append("DOCKER NOT INSTALLED", style="bold red") + error_text.append("\n\n", style="white") + error_text.append("The 'docker' CLI was not found in your PATH.\n", style="white") + error_text.append( + "Please install Docker and ensure the 'docker' command is available.\n\n", style="white" + ) + + panel = Panel( + error_text, + title="[bold white]STRIX", + title_align="left", + border_style="red", + padding=(1, 2), + ) + console.print("\n", panel, "\n") + sys.exit(1) + logger.debug("Docker CLI present") + + +def pull_docker_image() -> None: + from docker.errors import DockerException + + console = Console() + client = check_docker_connection() + + image = load_settings().runtime.image + + if image_exists(client, image): + logger.debug("Docker image already present locally: %s", image) + return + + logger.info("Pulling docker image: %s", image) + console.print() + console.print(f"[dim]Pulling image[/] {image}") + console.print("[dim yellow]This only happens on first run and may take a few minutes...[/]") + console.print() + + with console.status("[bold cyan]Downloading image layers...", spinner="dots") as status: + try: + layers_info: dict[str, str] = {} + last_update = "" + + for line in client.api.pull(image, stream=True, decode=True): + last_update = process_pull_line(line, layers_info, status, last_update) + + except DockerException as e: + logger.debug("Failed to pull docker image %s", image, exc_info=True) + console.print() + error_text = Text() + error_text.append("FAILED TO PULL IMAGE", style="bold red") + error_text.append("\n\n", style="white") + error_text.append(f"Could not download: {image}\n", style="white") + error_text.append(str(e), style="dim red") + + panel = Panel( + error_text, + title="[bold white]STRIX", + title_align="left", + border_style="red", + padding=(1, 2), + ) + console.print(panel, "\n") + sys.exit(1) + + logger.info("Docker image %s ready", image) + success_text = Text() + success_text.append("Docker image ready", style="#22c55e") + console.print(success_text) + console.print() diff --git a/strix/interface/interactive.py b/strix/interface/interactive.py new file mode 100644 index 00000000..3c228d03 --- /dev/null +++ b/strix/interface/interactive.py @@ -0,0 +1,38 @@ +"""Launch the interactive terminal interface.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + import argparse + + +logger = logging.getLogger(__name__) + + +class InteractiveSetupUnavailableError(RuntimeError): + """Raised when the interactive TUI cannot be launched.""" + + +async def run_tui(args: argparse.Namespace) -> None: + """Run the Bubble Tea TUI.""" + from strix.interface.tui.runtime import ( + GoTuiPreActivationError, + run_go_tui, + ) + + try: + await run_go_tui(args) + except GoTuiPreActivationError as exc: + raise InteractiveSetupUnavailableError( + f"The interactive interface could not start: {exc}" + ) from exc + + +__all__ = [ + "InteractiveSetupUnavailableError", + "run_tui", +] diff --git a/strix/interface/main.py b/strix/interface/main.py index 3f29603d..ceb14c26 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -5,54 +5,46 @@ Strix Agent Interface import argparse import asyncio +import contextlib import os -import shutil import sys -from datetime import UTC, datetime from pathlib import Path from rich.console import Console from rich.panel import Panel from rich.text import Text -from strix.config import ( - apply_config_override, - codex, - load_settings, - persist_current, +from strix.config import codex, load_settings, persist_current +from strix.core.paths import run_dir_for +from strix.interface.cli_args import parse_arguments +from strix.interface.environment import ( + check_docker_installed, + pull_docker_image, + validate_environment, +) +from strix.interface.interactive import ( + InteractiveSetupUnavailableError, + run_tui, +) +from strix.interface.scan_setup import ( + ModelConnectionError, + preflight_model_connection, + prepare_run, + telemetry_start, ) -from strix.config.settings import DEFAULT_MAX_TURNS -from strix.core.paths import run_dir_for, runtime_state_dir from strix.interface.update_check import ( is_binary_install, notify_update, prompt_update_if_available, - self_update, start_background_check, ) from strix.interface.utils import ( - assign_workspace_subdirs, build_final_stats_text, - check_docker_connection, - check_mountable_dir, - clone_repository, - collect_local_sources, - dedupe_local_targets, - generate_run_name, - image_exists, - infer_target_type, - is_whitebox_scan, - process_pull_line, - read_target_list_file, - resolve_diff_scope_context, - rewrite_localhost_targets, - validate_config_file, ) from strix.telemetry import posthog, scarf from strix.telemetry.logging import configure_dependency_logging -HOST_GATEWAY_HOSTNAME = "host.docker.internal" BEDROCK_MODEL_PREFIX = "bedrock/" BEDROCK_MISSING_MODULE_ERROR = "No module named 'boto3'" BEDROCK_EXTRA_HINT = ( @@ -71,166 +63,6 @@ import logging # noqa: E402 logger = logging.getLogger(__name__) -def validate_environment() -> None: - logger.info("Validating environment") - console = Console() - missing_required_vars = [] - missing_optional_vars = [] - - settings = load_settings() - - if codex.subscription_model(settings.llm.model): - if not codex.is_authenticated(): - console.print( - f"[red]STRIX_LLM={settings.llm.model} uses your ChatGPT subscription, " - "but you're not signed in.[/] Run [cyan]strix auth login chatgpt[/] first." - ) - sys.exit(1) - logger.info("Environment OK (ChatGPT subscription)") - return - - if not settings.llm.model: - missing_required_vars.append("STRIX_LLM") - - if not settings.llm.api_key: - missing_optional_vars.append("LLM_API_KEY") - - if not settings.llm.api_base: - missing_optional_vars.append("LLM_API_BASE") - - if not settings.integrations.perplexity_api_key: - missing_optional_vars.append("PERPLEXITY_API_KEY") - - if missing_required_vars: - error_text = Text() - error_text.append("MISSING REQUIRED ENVIRONMENT VARIABLES", style="bold red") - error_text.append("\n\n", style="white") - - for var in missing_required_vars: - error_text.append(f"β€’ {var}", style="bold yellow") - error_text.append(" is not set\n", style="white") - - if missing_optional_vars: - error_text.append("\nOptional environment variables:\n", style="dim white") - for var in missing_optional_vars: - error_text.append(f"β€’ {var}", style="dim yellow") - error_text.append(" is not set\n", style="dim white") - - error_text.append("\nRequired environment variables:\n", style="white") - for var in missing_required_vars: - if var == "STRIX_LLM": - error_text.append("β€’ ", style="white") - error_text.append("STRIX_LLM", style="bold cyan") - error_text.append( - " - Model name to use (e.g., 'openai/gpt-5.4' or " - "'anthropic/claude-opus-4-7')\n", - style="white", - ) - - if missing_optional_vars: - error_text.append("\nOptional environment variables:\n", style="white") - for var in missing_optional_vars: - if var == "LLM_API_KEY": - error_text.append("β€’ ", style="white") - error_text.append("LLM_API_KEY", style="bold cyan") - error_text.append( - " - API key for the LLM provider " - "(not needed for local models, Vertex AI, AWS, etc.)\n", - style="white", - ) - elif var == "LLM_API_BASE": - error_text.append("β€’ ", style="white") - error_text.append("LLM_API_BASE", style="bold cyan") - error_text.append( - " - Custom API base URL if using local models (e.g., Ollama, LMStudio)\n", - style="white", - ) - elif var == "PERPLEXITY_API_KEY": - error_text.append("β€’ ", style="white") - error_text.append("PERPLEXITY_API_KEY", style="bold cyan") - error_text.append( - " - API key for Perplexity AI web search (enables real-time research)\n", - style="white", - ) - elif var == "STRIX_REASONING_EFFORT": - error_text.append("β€’ ", style="white") - error_text.append("STRIX_REASONING_EFFORT", style="bold cyan") - error_text.append( - " - Reasoning effort level: none, minimal, low, medium, high, xhigh, " - "max (default: high)\n", - style="white", - ) - - error_text.append("\nExample setup:\n", style="white") - error_text.append("export STRIX_LLM='openai/gpt-5.4'\n", style="dim white") - - if missing_optional_vars: - for var in missing_optional_vars: - if var == "LLM_API_KEY": - error_text.append( - "export LLM_API_KEY='your-api-key-here' " - "# not needed for local models, Vertex AI, AWS, etc.\n", - style="dim white", - ) - elif var == "LLM_API_BASE": - error_text.append( - "export LLM_API_BASE='http://localhost:11434' " - "# needed for local models only\n", - style="dim white", - ) - elif var == "PERPLEXITY_API_KEY": - error_text.append( - "export PERPLEXITY_API_KEY='your-perplexity-key-here'\n", style="dim white" - ) - elif var == "STRIX_REASONING_EFFORT": - error_text.append( - "export STRIX_REASONING_EFFORT='high'\n", - style="dim white", - ) - - panel = Panel( - error_text, - title="[bold white]STRIX", - title_align="left", - border_style="red", - padding=(1, 2), - ) - - logger.debug("Missing required env vars: %s", missing_required_vars) - console.print("\n") - console.print(panel) - console.print() - sys.exit(1) - logger.info( - "Environment OK (optional missing: %s)", - missing_optional_vars or "none", - ) - - -def check_docker_installed() -> None: - if shutil.which("docker") is None: - logger.debug("Docker CLI not found in PATH") - console = Console() - error_text = Text() - error_text.append("DOCKER NOT INSTALLED", style="bold red") - error_text.append("\n\n", style="white") - error_text.append("The 'docker' CLI was not found in your PATH.\n", style="white") - error_text.append( - "Please install Docker and ensure the 'docker' command is available.\n\n", style="white" - ) - - panel = Panel( - error_text, - title="[bold white]STRIX", - title_align="left", - border_style="red", - padding=(1, 2), - ) - console.print("\n", panel, "\n") - sys.exit(1) - logger.debug("Docker CLI present") - - def _exception_messages(exc: BaseException) -> tuple[str, ...]: messages: list[str] = [] seen: set[int] = set() @@ -316,7 +148,6 @@ async def warm_up_llm(show_model_warning: bool = True) -> None: configure_sdk_model_defaults(settings) llm = settings.llm raw_model = (llm.model or "").strip() - if ( raw_model and "/" not in raw_model @@ -374,28 +205,7 @@ async def warm_up_llm(show_model_warning: bool = True) -> None: ), ) - model = StrixProvider().get_model(raw_model) - await asyncio.wait_for( - model.get_response( - system_instructions="You are a helpful assistant.", - input="Reply with just 'OK'.", - model_settings=make_model_settings( - None, - model_name=raw_model, - request_timeout=llm.timeout, - prompt_cache=False, - extra_headers=llm.extra_headers, - ), - tools=[], - output_schema=None, - handoffs=[], - tracing=ModelTracing.DISABLED, - previous_response_id=None, - conversation_id=None, - prompt=None, - ), - timeout=llm.timeout, - ) + await preflight_model_connection(raw_model, settings=settings) logger.info("LLM warm-up succeeded for model %s", (llm.model or "").strip()) if settings.dedupe.model: @@ -404,8 +214,6 @@ async def warm_up_llm(show_model_warning: bool = True) -> None: dedupe_model = settings.dedupe.model.strip() raw_model = dedupe_model deduper = StrixProvider().get_model(dedupe_model) - # Match the runtime path: send the dedupe key/endpoint per call so a - # separate-provider dedupe model authenticates during warm-up too. deduper_extra = _dedupe_extra_args(settings.dedupe) # A dedicated dedupe model may route to another provider, which must # never receive the main endpoint's headers; it has its own @@ -437,403 +245,12 @@ async def warm_up_llm(show_model_warning: bool = True) -> None: ) logger.info("LLM warm-up succeeded for dedupe model %s", dedupe_model) - except Exception as e: + except ModelConnectionError: + logger.debug("Model route warm-up failed", exc_info=True) + raise + except Exception as exc: logger.debug("LLM warm-up failed", exc_info=True) - error_text = Text() - sub_hint = _subscription_error_hint(e) - if sub_hint is not None: - # The model/backend answered with a clear, actionable rejection β€” - # show that instead of a generic "connection failed". - border_style = "yellow" - error_text.append("MODEL NOT AVAILABLE ON SUBSCRIPTION", style="bold yellow") - error_text.append("\n\n", style="white") - error_text.append(f"{sub_hint}\n", style="white") - error_text.append(f"\nDetails: {e}", style="dim white") - else: - border_style = "red" - error_text.append("LLM CONNECTION FAILED", style="bold red") - error_text.append("\n\n", style="white") - error_text.append( - "Could not establish connection to the language model.\n", style="white" - ) - error_text.append("Please check your configuration and try again.\n", style="white") - hint = _provider_import_hint(e, raw_model) - if hint is not None: - error_text.append(f"\n{hint}\n", style="bold yellow") - error_text.append(f"\nError: {e}", style="dim white") - - panel = Panel( - error_text, - title="[bold white]STRIX", - title_align="left", - border_style=border_style, - padding=(1, 2), - ) - - console.print("\n") - console.print(panel) - console.print() - sys.exit(1) - - -def get_version() -> str: - try: - from importlib.metadata import version - - return version("strix-agent") - except Exception: - return "unknown" - - -def _positive_budget(value: str) -> float: - try: - budget = float(value) - except ValueError as exc: - raise argparse.ArgumentTypeError(f"invalid float value: {value!r}") from exc - import math - - if not math.isfinite(budget) or budget <= 0: - raise argparse.ArgumentTypeError("must be a finite number greater than 0") - return budget - - -def _positive_int(value: str) -> int: - try: - parsed = int(value) - except ValueError as exc: - raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") from exc - if parsed <= 0: - raise argparse.ArgumentTypeError("must be an integer greater than 0") - return parsed - - -def parse_arguments() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Strix Multi-Agent Cybersecurity Penetration Testing Tool", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - # Web application penetration test - strix --target https://example.com - - # GitHub repository analysis - strix --target https://github.com/user/repo - strix --target git@github.com:user/repo.git - - # Local code analysis - strix --target ./my-project - - # Domain penetration test - strix --target example.com - - # IP address penetration test - strix --target 192.168.1.42 - - # Multiple targets (e.g., white-box testing with source and deployed app) - strix --target https://github.com/user/repo --target https://example.com - strix --target ./my-project --target https://staging.example.com --target https://prod.example.com - - # Targets from a file, one target per non-empty, non-comment line - strix --target-list ./targets.txt - - # Custom instructions (inline) - strix --target example.com --instruction "Focus on authentication vulnerabilities" - - # Custom instructions (from file) - strix --target example.com --instruction-file ./instructions.txt - strix --target https://app.com --instruction-file /path/to/detailed_instructions.md - """, - ) - - parser.add_argument( - "-v", - "--version", - action="version", - version=f"strix {get_version()}", - ) - - parser.add_argument( - "--update", - action="store_true", - help="Update strix to the latest version and exit. Self-updates the " - "standalone binary install; for pip/pipx/uv installs, prints the " - "matching upgrade command instead.", - ) - - parser.add_argument( - "-t", - "--target", - type=str, - action="append", - help="Target to test (URL, repository, local directory path, domain name, or IP address). " - "Local directories are mounted into the sandbox writable. " - "Can be specified multiple times for multi-target scans. " - "Fresh runs require --target or --target-list.", - ) - parser.add_argument( - "--target-list", - type=str, - action="append", - metavar="PATH", - help="Path to a file containing targets, one per non-empty, non-comment line. " - "Can be specified multiple times and combined with --target.", - ) - parser.add_argument( - "--instruction", - type=str, - help="Custom instructions for the penetration test. This can be " - "specific vulnerability types to focus on (e.g., 'Focus on IDOR and XSS'), " - "testing approaches (e.g., 'Perform thorough authentication testing'), " - "test credentials (e.g., 'Use the following credentials to access the app: " - "admin:password123'), " - "or areas of interest (e.g., 'Check login API endpoint for security issues').", - ) - - parser.add_argument( - "--instruction-file", - type=str, - help="Path to a file containing detailed custom instructions for the penetration test. " - "Use this option when you have lengthy or complex instructions saved in a file " - "(e.g., '--instruction-file ./detailed_instructions.txt').", - ) - - parser.add_argument( - "-n", - "--non-interactive", - action="store_true", - help=( - "Run in non-interactive mode (no TUI, exits on completion). " - "Default is interactive mode with TUI." - ), - ) - - parser.add_argument( - "-m", - "--scan-mode", - type=str, - choices=["quick", "standard", "deep"], - default="deep", - help=( - "Scan mode: " - "'quick' for fast CI/CD checks, " - "'standard' for routine testing, " - "'deep' for thorough security reviews (default). " - "Default: deep." - ), - ) - - parser.add_argument( - "--scope-mode", - type=str, - choices=["auto", "diff", "full"], - default="auto", - help=( - "Scope mode for code targets: " - "'auto' enables PR diff-scope in CI/headless runs, " - "'diff' forces changed-files scope, " - "'full' disables diff-scope." - ), - ) - - parser.add_argument( - "--diff-base", - type=str, - help=( - "Target branch or commit to compare against (e.g., origin/main). " - "Defaults to the repository's default branch." - ), - ) - - parser.add_argument( - "--config", - type=str, - help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json", - ) - - parser.add_argument( - "--max-budget", - dest="max_budget_usd", - metavar="USD", - type=_positive_budget, - default=None, - help=( - "Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached. " - "Graduated wrap-up warnings are sent to all agents as it is approached." - ), - ) - - parser.add_argument( - "--max-turns", - dest="max_turns", - metavar="N", - type=_positive_int, - default=DEFAULT_MAX_TURNS, - help=( - "Maximum turns per agent (> 0, default %(default)s). Each agent is force-stopped " - "when it reaches this limit, with graduated wrap-up warnings as it is approached." - ), - ) - - parser.add_argument( - "--resume", - type=str, - metavar="RUN_NAME", - help=( - "Resume a prior scan by its run name (the dir under ./strix_runs/). " - "Picks up the root + every non-terminal subagent's full LLM history " - "and agent topology. Skips fresh run-name generation." - ), - ) - - args = parser.parse_args() - - if args.update: - sys.exit(0 if self_update() else 1) - - if args.instruction and args.instruction_file: - parser.error( - "Cannot specify both --instruction and --instruction-file. Use one or the other." - ) - - if args.instruction_file: - instruction_path = Path(args.instruction_file) - try: - with instruction_path.open(encoding="utf-8") as f: - args.instruction = f.read().strip() - if not args.instruction: - parser.error(f"Instruction file '{instruction_path}' is empty") - except Exception as e: - parser.error(f"Failed to read instruction file '{instruction_path}': {e}") - - args.user_explicit_instruction = args.instruction if args.resume else None - - if args.resume: - if args.target or args.target_list: - parser.error( - "Cannot combine --resume with --target/--target-list. " - "--resume picks up where the prior run left off, including the " - "original target list." - ) - _load_resume_state(args, parser) - agents_path = runtime_state_dir(run_dir_for(args.resume)) / "agents.json" - if not agents_path.exists(): - parser.error( - f"--resume {args.resume}: missing {agents_path}. The run was " - f"persisted but never reached its first agent snapshot β€” " - f"there's nothing to resume from. Pick a fresh --run-name " - f"or remove --resume to start over with the same targets." - ) - else: - if not args.target and not args.target_list: - parser.error( - "the following arguments are required: -t/--target or --target-list " - "(or use --resume to continue a prior scan)" - ) - args.targets_info = [] - targets = list(args.target or []) - for target_list_path in args.target_list or []: - try: - targets.extend(read_target_list_file(target_list_path)) - except ValueError as e: - parser.error(str(e)) - - for target in targets: - try: - target_type, target_dict = infer_target_type(target) - - if target_type == "local_code": - display_target = target_dict.get("target_path", target) - else: - display_target = target - - args.targets_info.append( - {"type": target_type, "details": target_dict, "original": display_target} - ) - except ValueError as e: - parser.error(f"Invalid target '{target}': {e}") - - args.targets_info = dedupe_local_targets(args.targets_info) - - assign_workspace_subdirs(args.targets_info) - rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME) - - return args - - -def _persist_run_record(args: argparse.Namespace) -> None: - from strix.report.writer import write_run_record - - run_dir = run_dir_for(args.run_name) - run_dir.mkdir(parents=True, exist_ok=True) - run_record = { - "run_id": args.run_name, - "run_name": args.run_name, - "status": "running", - "start_time": datetime.now(UTC).isoformat(), - "end_time": None, - "auth_mode": codex.auth_mode(load_settings().llm.model), - "targets_info": args.targets_info, - "scan_mode": args.scan_mode, - "instruction": args.instruction, - "non_interactive": args.non_interactive, - "local_sources": getattr(args, "local_sources", []), - "diff_scope": getattr(args, "diff_scope", {"active": False}), - "scope_mode": args.scope_mode, - "diff_base": args.diff_base, - } - write_run_record(run_dir, run_record) - - -def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None: - """Populate ``args.targets_info`` and friends from a prior run's run.json.""" - from strix.report.writer import read_run_record - - run_dir = run_dir_for(args.resume) - state_path = run_dir / "run.json" - if not state_path.exists(): - parser.error( - f"--resume {args.resume}: no such run " - f"(missing {state_path}; remove --resume for a fresh start)" - ) - try: - state = read_run_record(run_dir) - except RuntimeError as exc: - parser.error(f"--resume {args.resume}: run.json unreadable: {exc}") - - args.targets_info = state.get("targets_info") or [] - if not args.targets_info: - parser.error(f"--resume {args.resume}: run.json has no targets_info") - - for target in args.targets_info: - if not isinstance(target, dict): - continue - details = target.get("details") or {} - if target.get("type") == "local_code" and details.get("target_path"): - try: - check_mountable_dir(Path(details["target_path"]).expanduser()) - except ValueError as exc: - parser.error(f"--resume {args.resume}: {exc}") - continue - if target.get("type") != "repository": - continue - cloned = details.get("cloned_repo_path") - if not cloned: - continue - if not Path(cloned).expanduser().exists(): - parser.error( - f"--resume {args.resume}: cloned repo at {cloned} is missing. " - f"It was deleted between runs. Pick a fresh --run-name to " - f"re-clone, or restore the directory before resuming." - ) - - if args.instruction is None: - args.instruction = state.get("instruction") - args.local_sources = collect_local_sources(args.targets_info) - if state.get("diff_scope"): - args.diff_scope = state.get("diff_scope") - persisted_scan_mode = state.get("scan_mode") - if persisted_scan_mode and args.scan_mode == "deep": - args.scan_mode = persisted_scan_mode + raise ModelConnectionError(raw_model, exc) from exc def display_completion_message(args: argparse.Namespace, results_path: Path) -> None: @@ -917,56 +334,79 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) -> notify_update(console) -def pull_docker_image() -> None: - from docker.errors import DockerException - +def _print_error_panel(title: str, message: str) -> None: console = Console() - client = check_docker_connection() + error_text = Text() + error_text.append(title, style="bold red") + error_text.append("\n\n", style="white") + error_text.append(message, style="white") + panel = Panel( + error_text, + title="[bold white]STRIX", + title_align="left", + border_style="red", + padding=(1, 2), + ) + console.print("\n") + console.print(panel) + console.print() - image = load_settings().runtime.image - if image_exists(client, image): - logger.debug("Docker image already present locally: %s", image) +def _print_model_connection_error(exc: BaseException, model_name: str) -> None: + console = Console() + error_text = Text() + sub_hint = _subscription_error_hint(exc) + if sub_hint is not None: + border_style = "yellow" + error_text.append("MODEL NOT AVAILABLE ON SUBSCRIPTION", style="bold yellow") + error_text.append("\n\n", style="white") + error_text.append(f"{sub_hint}\n", style="white") + error_text.append(f"\nDetails: {exc}", style="dim white") + else: + border_style = "red" + error_text.append("LLM CONNECTION FAILED", style="bold red") + error_text.append("\n\n", style="white") + error_text.append("Could not establish connection to the language model.\n", style="white") + error_text.append("Please check your configuration and try again.\n", style="white") + hint = _provider_import_hint(exc, model_name) + if hint is not None: + error_text.append(f"\n{hint}\n", style="bold yellow") + error_text.append(f"\nError: {exc}", style="dim white") + + panel = Panel( + error_text, + title="[bold white]STRIX", + title_align="left", + border_style=border_style, + padding=(1, 2), + ) + console.print("\n") + console.print(panel) + console.print() + + +def _bootstrap_scan(args: argparse.Namespace) -> None: + """Warm up the model and prepare the run for a non-interactive scan. + + Interactive launches only validate the environment here; the model + preflight and run preparation happen inside the TUI so the interface + paints immediately instead of waiting on a model round trip. + """ + validate_environment() + if not args.non_interactive: return - - logger.info("Pulling docker image: %s", image) - console.print() - console.print(f"[dim]Pulling image[/] {image}") - console.print("[dim yellow]This only happens on first run and may take a few minutes...[/]") - console.print() - - with console.status("[bold cyan]Downloading image layers...", spinner="dots") as status: - try: - layers_info: dict[str, str] = {} - last_update = "" - - for line in client.api.pull(image, stream=True, decode=True): - last_update = process_pull_line(line, layers_info, status, last_update) - - except DockerException as e: - logger.debug("Failed to pull docker image %s", image, exc_info=True) - console.print() - error_text = Text() - error_text.append("FAILED TO PULL IMAGE", style="bold red") - error_text.append("\n\n", style="white") - error_text.append(f"Could not download: {image}\n", style="white") - error_text.append(str(e), style="dim red") - - panel = Panel( - error_text, - title="[bold white]STRIX", - title_align="left", - border_style="red", - padding=(1, 2), - ) - console.print(panel, "\n") - sys.exit(1) - - logger.info("Docker image %s ready", image) - success_text = Text() - success_text.append("Docker image ready", style="#22c55e") - console.print(success_text) - console.print() + try: + asyncio.run(warm_up_llm(show_model_warning=True)) + except ModelConnectionError as exc: + _print_model_connection_error(exc, exc.model_name) + sys.exit(1) + persist_current() + try: + prepare_run(args) + except ValueError as e: + _print_error_panel("SCAN PREPARATION FAILED", str(e)) + sys.exit(1) + telemetry_start(args) def main() -> None: @@ -992,9 +432,6 @@ def main() -> None: args = parse_arguments() - if args.config: - apply_config_override(validate_config_file(args.config)) - start_background_check() if not args.non_interactive and prompt_update_if_available(Console()): if is_binary_install() and sys.platform != "win32": @@ -1004,67 +441,10 @@ def main() -> None: check_docker_installed() pull_docker_image() - validate_environment() - asyncio.run(warm_up_llm(show_model_warning=args.non_interactive)) - - persist_current() - - args.run_name = args.resume or generate_run_name(args.targets_info) - - if not args.resume: - for target_info in args.targets_info: - if target_info["type"] == "repository": - repo_url = target_info["details"]["target_repo"] - dest_name = target_info["details"].get("workspace_subdir") - cloned_path = clone_repository(repo_url, args.run_name, dest_name) - target_info["details"]["cloned_repo_path"] = cloned_path - - args.local_sources = collect_local_sources(args.targets_info) - try: - diff_scope = resolve_diff_scope_context( - local_sources=args.local_sources, - scope_mode=args.scope_mode, - diff_base=args.diff_base, - non_interactive=args.non_interactive, - ) - except ValueError as e: - console = Console() - error_text = Text() - error_text.append("DIFF SCOPE RESOLUTION FAILED", style="bold red") - error_text.append("\n\n", style="white") - error_text.append(str(e), style="white") - - panel = Panel( - error_text, - title="[bold white]STRIX", - title_align="left", - border_style="red", - padding=(1, 2), - ) - console.print("\n") - console.print(panel) - console.print() - sys.exit(1) - - args.diff_scope = diff_scope.metadata - if diff_scope.instruction_block: - if args.instruction: - args.instruction = f"{diff_scope.instruction_block}\n\n{args.instruction}" - else: - args.instruction = diff_scope.instruction_block - - _persist_run_record(args) - - _telemetry_start_kwargs = { - "model": load_settings().llm.model, - "auth_mode": codex.auth_mode(load_settings().llm.model), - "scan_mode": args.scan_mode, - "is_whitebox": is_whitebox_scan(args.targets_info), - "interactive": not args.non_interactive, - "has_instructions": bool(args.instruction), - } - posthog.start(**_telemetry_start_kwargs) - scarf.start(**_telemetry_start_kwargs) + # In setup mode the TUI collects the target, then runs prepare_run(), + # warm-up, and telemetry itself once the user starts the scan. + if not args.needs_setup: + _bootstrap_scan(args) from strix.report.state import get_global_report_state @@ -1075,9 +455,11 @@ def main() -> None: asyncio.run(run_cli(args)) else: - from strix.interface.tui import run_tui - asyncio.run(run_tui(args)) + except InteractiveSetupUnavailableError as exc: + exit_reason = "error" + _print_error_panel("INTERACTIVE SETUP UNAVAILABLE", str(exc)) + sys.exit(1) except KeyboardInterrupt: exit_reason = "interrupted" except Exception: @@ -1093,8 +475,16 @@ def main() -> None: "stopped", ) report_state.cleanup(status=status) - posthog.end(report_state, exit_reason=exit_reason) - scarf.end(report_state, exit_reason=exit_reason) + # Best-effort beacons on the way out. They reach the network, so a + # second Ctrl-C lands here; abandon them rather than trading a clean + # exit for a traceback. + with contextlib.suppress(KeyboardInterrupt, Exception): + posthog.end(report_state, exit_reason=exit_reason) + scarf.end(report_state, exit_reason=exit_reason) + + if not args.run_name: + # Setup mode where the user quit before starting a scan: nothing ran. + return results_path = run_dir_for(args.run_name) diff --git a/strix/interface/scan_setup.py b/strix/interface/scan_setup.py new file mode 100644 index 00000000..76353bfa --- /dev/null +++ b/strix/interface/scan_setup.py @@ -0,0 +1,223 @@ +"""Scan bootstrap shared by the CLI entry point and the TUI setup flow. + +Target resolution, run preparation, model preflight, and start-of-run +telemetry live here so ``strix.interface.main`` (the CLI) and +``strix.interface.tui.runtime`` (interactive setup) depend on one module +instead of each other. Everything raises ordinary exceptions; rendering +errors and exiting the process is the caller's job. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from strix.config import Settings, codex, load_settings +from strix.core.paths import run_dir_for +from strix.interface.utils import ( + assign_workspace_subdirs, + clone_repository, + collect_local_sources, + dedupe_local_targets, + derive_local_base_name, + generate_run_name, + infer_target_type, + is_whitebox_scan, + read_target_list_file, + resolve_diff_scope_context, + rewrite_localhost_targets, +) +from strix.telemetry import posthog, scarf + + +if TYPE_CHECKING: + import argparse + +logger = logging.getLogger(__name__) + +HOST_GATEWAY_HOSTNAME = "host.docker.internal" + + +class ModelConnectionError(RuntimeError): + """An ordinary model preflight failure, annotated with its model route.""" + + def __init__(self, model_name: str, cause: BaseException) -> None: + super().__init__(str(cause)) + self.model_name = model_name + + +async def preflight_model_connection( + model_name: str, + *, + settings: Settings | None = None, +) -> None: + """Verify the configured model route before starting a scan.""" + from agents.models.interface import ModelTracing + + from strix.config.models import StrixProvider, configure_sdk_model_defaults + from strix.core.inputs import make_model_settings + + resolved_settings = load_settings() if settings is None else settings + configure_sdk_model_defaults(resolved_settings) + model = StrixProvider().get_model(model_name) + request_settings = make_model_settings( + None, + model_name=model_name, + request_timeout=resolved_settings.llm.timeout, + prompt_cache=False, + extra_headers=resolved_settings.llm.extra_headers, + ) + await asyncio.wait_for( + model.get_response( + system_instructions="You are a helpful assistant.", + input="Reply with just 'OK'.", + model_settings=request_settings, + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + conversation_id=None, + prompt=None, + ), + timeout=resolved_settings.llm.timeout, + ) + + +def build_targets_info(args: argparse.Namespace) -> None: + """Populate ``args.targets_info`` from target/target-list inputs. + + Raises :class:`ValueError` with a user-facing message on any bad input so + callers can surface it via ``parser.error`` (CLI) or a console panel (home + page). + """ + args.targets_info = [] + targets = list(args.target or []) + for target_list_path in args.target_list or []: + targets.extend(read_target_list_file(target_list_path)) + + for target in targets: + try: + target_type, target_dict = infer_target_type(target) + except ValueError as e: + raise ValueError(f"Invalid target '{target}': {e}") from None + + if target_type == "local_code": + display_target = target_dict.get("target_path", target) + else: + display_target = target + + args.targets_info.append( + {"type": target_type, "details": target_dict, "original": display_target} + ) + + args.targets_info = dedupe_local_targets(args.targets_info) + + assign_workspace_subdirs(args.targets_info) + rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME) + + +def prepare_run(args: argparse.Namespace) -> None: + """Resolve the run name, clone repos, compute diff-scope, and persist state. + + Shared by the CLI startup path and the interactive TUI setup phase (once the + user has supplied a target via ``/target``). Mutates *args* in place and + raises :class:`ValueError` on any preparation failure. + """ + args.run_name = args.resume or generate_run_name(args.targets_info) + + if args.resume: + return + + for target_info in args.targets_info: + if target_info["type"] == "repository": + repo_url = target_info["details"]["target_repo"] + dest_name = target_info["details"].get("workspace_subdir") + cloned_path = clone_repository(repo_url, args.run_name, dest_name) + target_info["details"]["cloned_repo_path"] = cloned_path + + args.local_sources = collect_local_sources(args.targets_info) + diff_scope = resolve_diff_scope_context( + local_sources=args.local_sources, + scope_mode=args.scope_mode, + diff_base=args.diff_base, + non_interactive=args.non_interactive, + ) + args.diff_scope = diff_scope.metadata + if diff_scope.instruction_block: + if args.instruction: + args.instruction = f"{diff_scope.instruction_block}\n\n{args.instruction}" + else: + args.instruction = diff_scope.instruction_block + + attach_workspace_mount(args) + _persist_run_record(args) + + +def attach_workspace_mount(args: argparse.Namespace) -> None: + """Expose ``args.workspace_mount`` to the sandbox without making it a target. + + A workspace mount is a directory the agent works in, not something to test: + it stays out of ``targets_info``, so it carries no authorized scope, and it + is attached after diff-scope resolution so it contributes no diff context. + The instruction is the only source of truth for what to do with it. + """ + mount = getattr(args, "workspace_mount", None) + if not mount: + return + args.workspace_subdir = derive_local_base_name(mount) + local_sources = list(getattr(args, "local_sources", None) or []) + local_sources.append( + { + "source_path": mount, + "workspace_subdir": args.workspace_subdir, + "protect_metadata": True, + } + ) + args.local_sources = local_sources + + +def telemetry_start(args: argparse.Namespace) -> None: + model = load_settings().llm.model + kwargs = { + "model": model, + "auth_mode": codex.auth_mode(model), + "scan_mode": args.scan_mode, + "is_whitebox": is_whitebox_scan(args.targets_info), + "interactive": not args.non_interactive, + "has_instructions": bool(args.instruction), + } + posthog.start(**kwargs) + scarf.start(**kwargs) + + +def _persist_run_record(args: argparse.Namespace) -> None: + from strix.report.writer import write_run_record + + run_dir = run_dir_for(args.run_name) + run_dir.mkdir(parents=True, exist_ok=True) + run_record = { + "run_id": args.run_name, + "run_name": args.run_name, + "status": "running", + "start_time": datetime.now(UTC).isoformat(), + "end_time": None, + "auth_mode": codex.auth_mode(load_settings().llm.model), + "targets_info": args.targets_info, + "scan_mode": args.scan_mode, + "instruction": args.instruction, + # Kept apart from instruction, which carries the diff-scope preamble: the + # transcript replays this as the user's opening message. + "user_instruction": getattr(args, "user_instruction", None), + "non_interactive": args.non_interactive, + "local_sources": getattr(args, "local_sources", []), + # Persisted so --resume can remount the workspace: it is not a target, + # so it cannot be rebuilt from targets_info. + "workspace_mount": getattr(args, "workspace_mount", None), + "diff_scope": getattr(args, "diff_scope", {"active": False}), + "scope_mode": args.scope_mode, + "diff_base": args.diff_base, + } + write_run_record(run_dir, run_record) diff --git a/strix/interface/tui/__init__.py b/strix/interface/tui/__init__.py index 371ef812..541c0cdc 100644 --- a/strix/interface/tui/__init__.py +++ b/strix/interface/tui/__init__.py @@ -1,6 +1,6 @@ -"""Textual TUI interface.""" +"""Terminal user interface: Go/Bubble Tea frontend plus its Python runtime and backend.""" -from strix.interface.tui.app import StrixTUIApp, run_tui +from strix.interface.tui.live_view import TuiLiveView -__all__ = ["StrixTUIApp", "run_tui"] +__all__ = ["TuiLiveView"] diff --git a/strix/interface/tui/app.py b/strix/interface/tui/app.py deleted file mode 100644 index 5c61ff77..00000000 --- a/strix/interface/tui/app.py +++ /dev/null @@ -1,2100 +0,0 @@ -import argparse -import asyncio -import atexit -import contextlib -import logging -import signal -import sys -import threading -import webbrowser -from collections.abc import Callable -from importlib.metadata import PackageNotFoundError -from importlib.metadata import version as pkg_version -from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar - - -if TYPE_CHECKING: - from pygments.token import _TokenType - from textual.timer import Timer - -from rich.align import Align -from rich.console import Group -from rich.panel import Panel -from rich.style import Style -from rich.text import Span, Text -from textual import events, on -from textual.app import App, ComposeResult -from textual.binding import Binding -from textual.containers import Grid, Horizontal, Vertical, VerticalScroll -from textual.reactive import reactive -from textual.screen import ModalScreen -from textual.widgets import Button, Label, Static, TextArea, Tree -from textual.widgets.tree import TreeNode - -from strix.config import load_settings -from strix.config.models import is_recommended_or_frontier_model -from strix.config.settings import DEFAULT_MAX_TURNS -from strix.core.hooks import BudgetExceededError -from strix.core.runner import run_strix_scan -from strix.interface.tui.live_view import TuiLiveView -from strix.interface.tui.messages import send_user_message_to_agent -from strix.interface.tui.renderers import render_tool_widget -from strix.interface.tui.renderers.agent_message_renderer import AgentMessageRenderer -from strix.interface.tui.renderers.user_message_renderer import UserMessageRenderer -from strix.interface.utils import build_tui_stats_text -from strix.report.state import ReportState, set_global_report_state -from strix.report.writer import ( - guess_language_name, - parse_fenced_code, - resolve_lexer, - safe_fence, -) -from strix.runtime import session_manager - - -logger = logging.getLogger(__name__) - - -def get_package_version() -> str: - try: - return pkg_version("strix-agent") - except PackageNotFoundError: - return "dev" - - -class ChatTextArea(TextArea): # type: ignore[misc] - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self._app_reference: StrixTUIApp | None = None - - def set_app_reference(self, app: "StrixTUIApp") -> None: - self._app_reference = app - - def on_mount(self) -> None: - self._update_height() - - def _on_key(self, event: events.Key) -> None: - if event.key == "shift+enter": - self.insert("\n") - event.prevent_default() - return - - if event.key == "enter" and self._app_reference: - text_content = str(self.text) # type: ignore[has-type] - message = text_content.strip() - if message: - self.text = "" - - self._app_reference._send_user_message(message) - - event.prevent_default() - return - - super()._on_key(event) - - @on(TextArea.Changed) # type: ignore[misc] - def _update_height(self, _event: TextArea.Changed | None = None) -> None: - if not self.parent: - return - - line_count = self.document.line_count - target_lines = min(max(1, line_count), 8) - - new_height = target_lines + 2 - - if self.parent.styles.height != new_height: - self.parent.styles.height = new_height - self.scroll_cursor_visible() - - -class SplashScreen(Static): # type: ignore[misc] - ALLOW_SELECT = False - PRIMARY_GREEN = "#22c55e" - BANNER = ( - " β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—\n" - " β–ˆβ–ˆβ•”β•β•β•β•β•β•šβ•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•\n" - " β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ•”β•\n" - " β•šβ•β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β–ˆβ–ˆβ•—\n" - " β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β• β–ˆβ–ˆβ•—\n" - " β•šβ•β•β•β•β•β•β• β•šβ•β• β•šβ•β• β•šβ•β•β•šβ•β•β•šβ•β• β•šβ•β•" - ) - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self._animation_step = 0 - self._animation_timer: Timer | None = None - self._panel_static: Static | None = None - self._version = "dev" - self._non_frontier_model: str | None = None - - def compose(self) -> ComposeResult: - self._version = get_package_version() - try: - model = (load_settings().llm.model or "").strip() - except Exception: - model = "" - if model and not is_recommended_or_frontier_model(model): - self._non_frontier_model = model - self._animation_step = 0 - start_line = self._build_start_line_text(self._animation_step) - panel = self._build_panel(start_line) - - panel_static = Static(panel, id="splash_content") - self._panel_static = panel_static - yield panel_static - - def on_mount(self) -> None: - self._animation_timer = self.set_interval(0.1, self._animate_start_line) - - def on_unmount(self) -> None: - if self._animation_timer is not None: - self._animation_timer.stop() - self._animation_timer = None - - def _animate_start_line(self) -> None: - if not self._panel_static: - return - - self._animation_step += 1 - start_line = self._build_start_line_text(self._animation_step) - panel = self._build_panel(start_line) - self._panel_static.update(panel) - - def _build_panel(self, start_line: Text) -> Panel: - rows = [ - Align.center(Text(self.BANNER.strip("\n"), style=self.PRIMARY_GREEN, justify="center")), - Align.center(Text(" ")), - Align.center(self._build_welcome_text()), - Align.center(self._build_version_text()), - Align.center(self._build_tagline_text()), - Align.center(Text(" ")), - Align.center(start_line.copy()), - Align.center(Text(" ")), - Align.center(self._build_url_text()), - ] - if self._non_frontier_model: - rows.extend( - ( - Align.center(Text(" ")), - Align.center(self._build_model_warning_text(self._non_frontier_model)), - ) - ) - - return Panel.fit(Group(*rows), border_style=self.PRIMARY_GREEN, padding=(1, 6)) - - @staticmethod - def _build_model_warning_text(model: str) -> Text: - text = Text("⚠ ", style=Style(color="yellow", bold=True)) - text.append(model, style=Style(color="cyan", bold=True)) - text.append( - " is not a recommended frontier model - pentest quality could be degraded", - style=Style(color="yellow"), - ) - return text - - def _build_url_text(self) -> Text: - return Text("strix.ai", style=Style(color=self.PRIMARY_GREEN, bold=True)) - - def _build_welcome_text(self) -> Text: - text = Text("Welcome to ", style=Style(color="white", bold=True)) - text.append("Strix", style=Style(color=self.PRIMARY_GREEN, bold=True)) - text.append("!", style=Style(color="white", bold=True)) - return text - - def _build_version_text(self) -> Text: - return Text(f"v{self._version}", style=Style(color="white", dim=True)) - - def _build_tagline_text(self) -> Text: - return Text("Open-source AI hackers for your apps", style=Style(color="white", dim=True)) - - def _build_start_line_text(self, phase: int) -> Text: - full_text = "Starting Strix Agent" - text_len = len(full_text) - - shine_pos = phase % (text_len + 8) - - text = Text() - for i, char in enumerate(full_text): - dist = abs(i - shine_pos) - - if dist <= 1: - style = Style(color="bright_white", bold=True) - elif dist <= 3: - style = Style(color="white", bold=True) - elif dist <= 5: - style = Style(color="#a3a3a3") - else: - style = Style(color="#525252") - - text.append(char, style=style) - - return text - - -class HelpScreen(ModalScreen): # type: ignore[misc] - def compose(self) -> ComposeResult: - yield Grid( - Label("Strix Help", id="help_title"), - Label( - "F1 Help\nCtrl+Q/C Quit\nESC Stop Agent\n" - "Enter Send message to agent\nTab Switch panels\n↑/↓ Navigate tree", - id="help_content", - ), - id="dialog", - ) - - def on_key(self, _event: events.Key) -> None: - self.app.pop_screen() - - -class StopAgentScreen(ModalScreen): # type: ignore[misc] - def __init__(self, agent_name: str, agent_id: str): - super().__init__() - self.agent_name = agent_name - self.agent_id = agent_id - - def compose(self) -> ComposeResult: - yield Grid( - Label(f"πŸ›‘ Stop '{self.agent_name}'?", id="stop_agent_title"), - Grid( - Button("Yes", variant="error", id="stop_agent"), - Button("No", variant="default", id="cancel_stop"), - id="stop_agent_buttons", - ), - id="stop_agent_dialog", - ) - - def on_mount(self) -> None: - cancel_button = self.query_one("#cancel_stop", Button) - cancel_button.focus() - - def on_key(self, event: events.Key) -> None: - if event.key in ("left", "right", "up", "down"): - focused = self.focused - - if focused and focused.id == "stop_agent": - cancel_button = self.query_one("#cancel_stop", Button) - cancel_button.focus() - else: - stop_button = self.query_one("#stop_agent", Button) - stop_button.focus() - - event.prevent_default() - elif event.key == "enter": - focused = self.focused - if focused and isinstance(focused, Button): - focused.press() - event.prevent_default() - elif event.key == "escape": - self.app.pop_screen() - event.prevent_default() - - def on_button_pressed(self, event: Button.Pressed) -> None: - self.app.pop_screen() - if event.button.id == "stop_agent": - self.app.action_confirm_stop_agent(self.agent_id) - - -class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc] - SEVERITY_COLORS: ClassVar[dict[str, str]] = { - "critical": "#dc2626", # Red - "high": "#ea580c", # Orange - "medium": "#d97706", # Amber - "low": "#22c55e", # Green - "info": "#3b82f6", # Blue - } - - FIELD_STYLE: ClassVar[str] = "bold #4ade80" - - def __init__(self, vulnerability: dict[str, Any]) -> None: - super().__init__() - self.vulnerability = vulnerability - - def compose(self) -> ComposeResult: - content = self._render_vulnerability() - yield Grid( - VerticalScroll(Static(content, id="vuln_detail_content"), id="vuln_detail_scroll"), - Horizontal( - Button("Copy", variant="default", id="copy_vuln_detail"), - Button("Done", variant="default", id="close_vuln_detail"), - id="vuln_detail_buttons", - ), - id="vuln_detail_dialog", - ) - - def on_mount(self) -> None: - close_button = self.query_one("#close_vuln_detail", Button) - close_button.focus() - - def _get_cvss_color(self, cvss_score: float) -> str: - if cvss_score >= 9.0: - return "#dc2626" - if cvss_score >= 7.0: - return "#ea580c" - if cvss_score >= 4.0: - return "#d97706" - if cvss_score >= 0.1: - return "#65a30d" - return "#6b7280" - - def _highlight_python(self, code: str, language: str | None = None) -> Text: - try: - from pygments.styles import get_style_by_name - - lexer = resolve_lexer(language, code) - style = get_style_by_name("native") - colors = { - token: f"#{style_def['color']}" for token, style_def in style if style_def["color"] - } - - text = Text() - for token_type, token_value in lexer.get_tokens(code): - if not token_value: - continue - color = None - tt: _TokenType | None = token_type - while tt: - if tt in colors: - color = colors[tt] - break - tt = tt.parent - text.append(token_value, style=color) - except (ImportError, KeyError, AttributeError): - return Text(code) - else: - return text - - def _render_vulnerability(self) -> Text: - vuln = self.vulnerability - text = Text() - - text.append("🐞 ") - text.append("Vulnerability Report", style="bold #ea580c") - - agent_name = vuln.get("agent_name", "") - if agent_name: - text.append("\n\n") - text.append("Agent: ", style=self.FIELD_STYLE) - text.append(agent_name) - - title = vuln.get("title", "") - if title: - text.append("\n\n") - text.append("Title: ", style=self.FIELD_STYLE) - text.append(title) - - severity = vuln.get("severity", "") - if severity: - text.append("\n\n") - text.append("Severity: ", style=self.FIELD_STYLE) - severity_color = self.SEVERITY_COLORS.get(severity.lower(), "#6b7280") - text.append(severity.upper(), style=f"bold {severity_color}") - - cvss_score = vuln.get("cvss") - if cvss_score is not None: - text.append("\n\n") - text.append("CVSS Score: ", style=self.FIELD_STYLE) - cvss_color = self._get_cvss_color(float(cvss_score)) - text.append(str(cvss_score), style=f"bold {cvss_color}") - - target = vuln.get("target", "") - if target: - text.append("\n\n") - text.append("Target: ", style=self.FIELD_STYLE) - text.append(target) - - dep_meta = vuln.get("dependency_metadata") or {} - for label, key in ( - ("Package", "package_name"), - ("Ecosystem", "package_ecosystem"), - ("Installed Version", "installed_version"), - ("Fixed Version", "fixed_version"), - ): - value = dep_meta.get(key) - if value: - text.append("\n\n") - text.append(f"{label}: ", style=self.FIELD_STYLE) - text.append(str(value)) - - endpoint = vuln.get("endpoint", "") - if endpoint: - text.append("\n\n") - text.append("Endpoint: ", style=self.FIELD_STYLE) - text.append(endpoint) - - method = vuln.get("method", "") - if method: - text.append("\n\n") - text.append("Method: ", style=self.FIELD_STYLE) - text.append(method) - - cve = vuln.get("cve", "") - if cve: - text.append("\n\n") - text.append("CVE: ", style=self.FIELD_STYLE) - text.append(cve) - - cwe = vuln.get("cwe", "") - if cwe: - text.append("\n\n") - text.append("CWE: ", style=self.FIELD_STYLE) - text.append(cwe) - - fix_effort = vuln.get("fix_effort", "") - if fix_effort: - text.append("\n\n") - text.append("Fix Effort: ", style=self.FIELD_STYLE) - text.append(str(fix_effort).title()) - - cvss_breakdown = vuln.get("cvss_breakdown", {}) - if cvss_breakdown: - cvss_parts = [] - if cvss_breakdown.get("attack_vector"): - cvss_parts.append(f"AV:{cvss_breakdown['attack_vector']}") - if cvss_breakdown.get("attack_complexity"): - cvss_parts.append(f"AC:{cvss_breakdown['attack_complexity']}") - if cvss_breakdown.get("privileges_required"): - cvss_parts.append(f"PR:{cvss_breakdown['privileges_required']}") - if cvss_breakdown.get("user_interaction"): - cvss_parts.append(f"UI:{cvss_breakdown['user_interaction']}") - if cvss_breakdown.get("scope"): - cvss_parts.append(f"S:{cvss_breakdown['scope']}") - if cvss_breakdown.get("confidentiality"): - cvss_parts.append(f"C:{cvss_breakdown['confidentiality']}") - if cvss_breakdown.get("integrity"): - cvss_parts.append(f"I:{cvss_breakdown['integrity']}") - if cvss_breakdown.get("availability"): - cvss_parts.append(f"A:{cvss_breakdown['availability']}") - if cvss_parts: - text.append("\n\n") - text.append("CVSS Vector: ", style=self.FIELD_STYLE) - text.append("/".join(cvss_parts), style="dim") - - description = vuln.get("description", "") - if description: - text.append("\n\n") - text.append("Description", style=self.FIELD_STYLE) - text.append("\n") - text.append(description) - - impact = vuln.get("impact", "") - if impact: - text.append("\n\n") - text.append("Impact", style=self.FIELD_STYLE) - text.append("\n") - text.append(impact) - - technical_analysis = vuln.get("technical_analysis", "") - if technical_analysis: - text.append("\n\n") - text.append("Technical Analysis", style=self.FIELD_STYLE) - text.append("\n") - text.append(technical_analysis) - - evidence = vuln.get("evidence", "") - if evidence: - text.append("\n\n") - text.append("Evidence", style=self.FIELD_STYLE) - text.append("\n") - text.append(evidence) - - poc_description = vuln.get("poc_description", "") - if poc_description: - text.append("\n\n") - text.append("PoC Description", style=self.FIELD_STYLE) - text.append("\n") - text.append(poc_description) - - poc_script_code = vuln.get("poc_script_code", "") - if poc_script_code: - poc_language, poc_code = parse_fenced_code(poc_script_code) - text.append("\n\n") - text.append("PoC Code", style=self.FIELD_STYLE) - text.append("\n") - text.append_text(self._highlight_python(poc_code, poc_language)) - - remediation_steps = vuln.get("remediation_steps", "") - if remediation_steps: - text.append("\n\n") - text.append("Remediation", style=self.FIELD_STYLE) - text.append("\n") - text.append(remediation_steps) - - assumptions = vuln.get("assumptions", "") - if assumptions: - text.append("\n\n") - text.append("Assumptions", style=self.FIELD_STYLE) - text.append("\n") - text.append(assumptions) - - return text - - def _get_markdown_report(self) -> str: - """Get Markdown version of vulnerability report for clipboard.""" - vuln = self.vulnerability - lines: list[str] = [] - - title = vuln.get("title", "Untitled Vulnerability") - lines.append(f"# {title}") - lines.append("") - - if vuln.get("id"): - lines.append(f"**ID:** {vuln['id']}") - if vuln.get("severity"): - lines.append(f"**Severity:** {vuln['severity'].upper()}") - if vuln.get("timestamp"): - lines.append(f"**Found:** {vuln['timestamp']}") - if vuln.get("agent_name"): - lines.append(f"**Agent:** {vuln['agent_name']}") - if vuln.get("target"): - lines.append(f"**Target:** {vuln['target']}") - dep_meta = vuln.get("dependency_metadata") or {} - if dep_meta.get("package_name"): - lines.append(f"**Package:** {dep_meta['package_name']}") - if dep_meta.get("package_ecosystem"): - lines.append(f"**Ecosystem:** {dep_meta['package_ecosystem']}") - if dep_meta.get("installed_version"): - lines.append(f"**Installed Version:** {dep_meta['installed_version']}") - if dep_meta.get("fixed_version"): - lines.append(f"**Fixed Version:** {dep_meta['fixed_version']}") - if vuln.get("endpoint"): - lines.append(f"**Endpoint:** {vuln['endpoint']}") - if vuln.get("method"): - lines.append(f"**Method:** {vuln['method']}") - if vuln.get("cve"): - lines.append(f"**CVE:** {vuln['cve']}") - if vuln.get("cwe"): - lines.append(f"**CWE:** {vuln['cwe']}") - if vuln.get("cvss") is not None: - lines.append(f"**CVSS:** {vuln['cvss']}") - if vuln.get("fix_effort"): - lines.append(f"**Fix Effort:** {str(vuln['fix_effort']).title()}") - - cvss_breakdown = vuln.get("cvss_breakdown", {}) - if cvss_breakdown: - abbrevs = { - "attack_vector": "AV", - "attack_complexity": "AC", - "privileges_required": "PR", - "user_interaction": "UI", - "scope": "S", - "confidentiality": "C", - "integrity": "I", - "availability": "A", - } - parts = [ - f"{abbrevs.get(k, k)}:{v}" for k, v in cvss_breakdown.items() if v and k in abbrevs - ] - if parts: - lines.append(f"**CVSS Vector:** {'/'.join(parts)}") - - lines.append("") - lines.append("## Description") - lines.append("") - lines.append(vuln.get("description") or "No description provided.") - - if vuln.get("impact"): - lines.extend(["", "## Impact", "", vuln["impact"]]) - - if vuln.get("technical_analysis"): - lines.extend(["", "## Technical Analysis", "", vuln["technical_analysis"]]) - - if vuln.get("evidence"): - lines.extend(["", "## Evidence", "", vuln["evidence"]]) - - if vuln.get("poc_description") or vuln.get("poc_script_code"): - lines.extend(["", "## Proof of Concept", ""]) - if vuln.get("poc_description"): - lines.append(vuln["poc_description"]) - lines.append("") - if vuln.get("poc_script_code"): - poc_language, poc_code = parse_fenced_code(vuln["poc_script_code"]) - fence_lang = poc_language or guess_language_name(poc_code) - fence = safe_fence(poc_code) - lines.append(f"{fence}{fence_lang}") - lines.append(poc_code) - lines.append(fence) - - if vuln.get("code_locations"): - lines.extend(["", "## Code Analysis", ""]) - for i, loc in enumerate(vuln["code_locations"]): - file_ref = loc.get("file", "unknown") - line_ref = "" - if loc.get("start_line") is not None: - if loc.get("end_line") and loc["end_line"] != loc["start_line"]: - line_ref = f" (lines {loc['start_line']}-{loc['end_line']})" - else: - line_ref = f" (line {loc['start_line']})" - lines.append(f"**Location {i + 1}:** `{file_ref}`{line_ref}") - if loc.get("label"): - lines.append(f" {loc['label']}") - if loc.get("snippet"): - snippet = str(loc["snippet"]) - snippet_fence = safe_fence(snippet) - lines.append(f"{snippet_fence}\n{snippet}\n{snippet_fence}") - if loc.get("fix_before") or loc.get("fix_after"): - lines.append("**Suggested Fix:**") - lines.append("```diff") - if loc.get("fix_before"): - lines.extend(f"- {line}" for line in loc["fix_before"].splitlines()) - if loc.get("fix_after"): - lines.extend(f"+ {line}" for line in loc["fix_after"].splitlines()) - lines.append("```") - lines.append("") - - if vuln.get("remediation_steps"): - lines.extend(["", "## Remediation", "", vuln["remediation_steps"]]) - - if vuln.get("assumptions"): - lines.extend(["", "## Assumptions", "", vuln["assumptions"]]) - - lines.append("") - return "\n".join(lines) - - def on_key(self, event: events.Key) -> None: - if event.key == "escape": - self.app.pop_screen() - event.prevent_default() - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "copy_vuln_detail": - markdown_text = self._get_markdown_report() - self.app.copy_to_clipboard(markdown_text) - - copy_button = self.query_one("#copy_vuln_detail", Button) - copy_button.label = "Copied!" - self.set_timer(1.5, lambda: setattr(copy_button, "label", "Copy")) - elif event.button.id == "close_vuln_detail": - self.app.pop_screen() - - -class VulnerabilityItem(Static): # type: ignore[misc] - def __init__(self, label: Text, vuln_data: dict[str, Any], **kwargs: Any) -> None: - super().__init__(label, **kwargs) - self.vuln_data = vuln_data - - def on_click(self, _event: events.Click) -> None: - """Handle click to open vulnerability detail.""" - self.app.push_screen(VulnerabilityDetailScreen(self.vuln_data)) - - -class VulnerabilitiesPanel(VerticalScroll): # type: ignore[misc] - SEVERITY_COLORS: ClassVar[dict[str, str]] = { - "critical": "#dc2626", # Red - "high": "#ea580c", # Orange - "medium": "#d97706", # Amber - "low": "#22c55e", # Green - "info": "#3b82f6", # Blue - } - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self._vulnerabilities: list[dict[str, Any]] = [] - - def compose(self) -> ComposeResult: - return [] - - def update_vulnerabilities(self, vulnerabilities: list[dict[str, Any]]) -> None: - """Update the list of vulnerabilities and re-render.""" - if self._vulnerabilities == vulnerabilities: - return - self._vulnerabilities = list(vulnerabilities) - self._render_panel() - - def _render_panel(self) -> None: - """Render the vulnerabilities panel content.""" - for child in list(self.children): - if isinstance(child, VulnerabilityItem): - child.remove() - - if not self._vulnerabilities: - return - - for vuln in self._vulnerabilities: - severity = vuln.get("severity", "info").lower() - title = vuln.get("title", "Unknown Vulnerability") - color = self.SEVERITY_COLORS.get(severity, "#3b82f6") - - label = Text() - label.append("● ", style=Style(color=color)) - label.append(title, style=Style(color="#d4d4d4")) - - item = VulnerabilityItem(label, vuln, classes="vuln-item") - self.mount(item) - - -class QuitScreen(ModalScreen): # type: ignore[misc] - def compose(self) -> ComposeResult: - yield Grid( - Label("Quit Strix?", id="quit_title"), - Grid( - Button("Yes", variant="error", id="quit"), - Button("No", variant="default", id="cancel"), - id="quit_buttons", - ), - id="quit_dialog", - ) - - def on_mount(self) -> None: - cancel_button = self.query_one("#cancel", Button) - cancel_button.focus() - - def on_key(self, event: events.Key) -> None: - if event.key in ("left", "right", "up", "down"): - focused = self.focused - - if focused and focused.id == "quit": - cancel_button = self.query_one("#cancel", Button) - cancel_button.focus() - else: - quit_button = self.query_one("#quit", Button) - quit_button.focus() - - event.prevent_default() - elif event.key == "enter": - focused = self.focused - if focused and isinstance(focused, Button): - focused.press() - event.prevent_default() - elif event.key == "escape": - self.app.pop_screen() - event.prevent_default() - - async def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "quit": - await self.app.action_custom_quit() - else: - self.app.pop_screen() - - -class StrixTUIApp(App): # type: ignore[misc] - CSS_PATH = str(Path(__file__).resolve().parent.parent / "assets" / "tui_styles.tcss") - ALLOW_SELECT = True - - SIDEBAR_MIN_WIDTH = 120 - - selected_agent_id: reactive[str | None] = reactive(default=None) - show_splash: reactive[bool] = reactive(default=True) - - BINDINGS: ClassVar[list[Binding]] = [ - Binding("f1", "toggle_help", "Help", priority=True), - Binding("ctrl+q", "request_quit", "Quit", priority=True), - Binding("ctrl+c", "request_quit", "Quit", priority=True), - Binding("escape", "stop_selected_agent", "Stop Agent", priority=True), - Binding("ctrl+o", "open_viewer", "Open Viewer", priority=True), - ] - - def __init__(self, args: argparse.Namespace): - super().__init__() - self.args = args - self.scan_config = self._build_scan_config(args) - - self.report_state = ReportState(self.scan_config["run_name"]) - self.report_state.hydrate_from_run_dir() - self.report_state.set_scan_config(self.scan_config) - self.report_state.save_run_data() - set_global_report_state(self.report_state) - self.live_view = TuiLiveView() - self.live_view.hydrate_from_run_dir(self.report_state.get_run_dir()) - self._agent_graph_sync_future: Any | None = None - - from strix.core.agents import AgentCoordinator - - self.coordinator = AgentCoordinator() - - self.agent_nodes: dict[str, TreeNode] = {} - - self._displayed_agents: set[str] = set() - self._displayed_events: list[str] = [] - - self._scan_thread: threading.Thread | None = None - self._viewer_httpd: Any = None - self._viewer_url: str | None = None - self._scan_loop: asyncio.AbstractEventLoop | None = None - self._scan_stop_event = threading.Event() - self._scan_completed = threading.Event() - self._scan_error: BaseException | None = None - self._startup_status = "Starting up" - self._startup_status_step = 0 - self._error_noted_agents: set[str] = set() - self._budget_pause_notified = False - - self._spinner_frame_index: int = 0 - self._sweep_num_squares: int = 6 - self._sweep_colors: list[str] = [ - "#000000", # Dimmest (shows dot) - "#031a09", - "#052e16", - "#0d4a2a", - "#15803d", - "#22c55e", - "#4ade80", - "#86efac", # Brightest - ] - self._dot_animation_timer: Any | None = None - self._pending_scroll_end = False - - self._setup_cleanup_handlers() - - def _build_scan_config(self, args: argparse.Namespace) -> dict[str, Any]: - return { - "scan_id": args.run_name, - "targets": args.targets_info, - "user_instructions": args.instruction or "", - "run_name": args.run_name, - "diff_scope": getattr(args, "diff_scope", {"active": False}), - "scan_mode": getattr(args, "scan_mode", "deep"), - "non_interactive": bool(getattr(args, "non_interactive", False)), - "local_sources": getattr(args, "local_sources", None) or [], - "scope_mode": getattr(args, "scope_mode", "auto"), - "diff_base": getattr(args, "diff_base", None), - "resume_instruction": getattr(args, "user_explicit_instruction", None) or "", - } - - def _setup_cleanup_handlers(self) -> None: - def cleanup_on_exit() -> None: - self.report_state.cleanup() - - def signal_handler(_signum: int, _frame: Any) -> None: - self._fire_sandbox_cleanup() - self.report_state.cleanup(status="interrupted") - sys.exit(0) - - atexit.register(cleanup_on_exit) - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - if hasattr(signal, "SIGHUP"): - signal.signal(signal.SIGHUP, signal_handler) - - def compose(self) -> ComposeResult: - if self.show_splash: - yield SplashScreen(id="splash_screen") - - def watch_show_splash(self, show_splash: bool) -> None: - if not show_splash and self.is_mounted: - try: - splash = self.query_one("#splash_screen") - splash.remove() - except ValueError: - pass - - main_container = Vertical(id="main_container") - - self.mount(main_container) - - content_container = Horizontal(id="content_container") - main_container.mount(content_container) - - chat_area_container = Vertical(id="chat_area_container") - - chat_display = Static("", id="chat_display") - chat_history = VerticalScroll(chat_display, id="chat_history") - chat_history.can_focus = True - - status_text = Static("", id="status_text") - status_text.ALLOW_SELECT = False - keymap_indicator = Static("", id="keymap_indicator") - keymap_indicator.ALLOW_SELECT = False - - agent_status_display = Horizontal( - status_text, keymap_indicator, id="agent_status_display", classes="hidden" - ) - - chat_prompt = Static("> ", id="chat_prompt") - chat_prompt.ALLOW_SELECT = False - chat_input = ChatTextArea( - "", - id="chat_input", - show_line_numbers=False, - ) - chat_input.set_app_reference(self) - chat_input_container = Horizontal(chat_prompt, chat_input, id="chat_input_container") - - agents_tree = Tree("Agents", id="agents_tree") - agents_tree.root.expand() - agents_tree.show_root = False - - agents_tree.show_guide = True - agents_tree.guide_depth = 3 - agents_tree.guide_style = "dashed" - - stats_display = Static("", id="stats_display") - stats_scroll = VerticalScroll(stats_display, id="stats_scroll") - - vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel") - - viewer_cta = Static(self._viewer_cta_markup(), id="viewer_cta") - viewer_cta.ALLOW_SELECT = False - - sidebar = Vertical( - viewer_cta, agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar" - ) - - content_container.mount(chat_area_container) - content_container.mount(sidebar) - - chat_area_container.mount(chat_history) - chat_area_container.mount(agent_status_display) - chat_area_container.mount(chat_input_container) - - self.call_after_refresh(self._focus_chat_input) - - def _focus_chat_input(self) -> None: - if len(self.screen_stack) > 1 or self.show_splash: - return - - if not self.is_mounted: - return - - try: - chat_input = self.query_one("#chat_input", ChatTextArea) - chat_input.show_vertical_scrollbar = False - chat_input.show_horizontal_scrollbar = False - chat_input.focus() - except (ValueError, Exception): - self.call_after_refresh(self._focus_chat_input) - - def _focus_agents_tree(self) -> None: - if len(self.screen_stack) > 1 or self.show_splash: - return - - if not self.is_mounted: - return - - try: - agents_tree = self.query_one("#agents_tree", Tree) - agents_tree.focus() - - if agents_tree.root.children: - first_node = agents_tree.root.children[0] - agents_tree.select_node(first_node) - except (ValueError, Exception): - self.call_after_refresh(self._focus_agents_tree) - - def on_mount(self) -> None: - self.title = "strix" - - self.set_timer(4.5, self._hide_splash_screen) - - def _hide_splash_screen(self) -> None: - self.show_splash = False - - self._start_scan_thread() - - self.set_interval(0.5, self._update_ui) - - def _update_ui(self) -> None: - if self.show_splash: - return - - if len(self.screen_stack) > 1: - return - - if not self.is_mounted: - return - - try: - chat_history = self.query_one("#chat_history", VerticalScroll) - agents_tree = self.query_one("#agents_tree", Tree) - - if not self._is_widget_safe(chat_history) or not self._is_widget_safe(agents_tree): - return - except (ValueError, Exception): - return - - self._sync_agent_graph() - - for agent_id, agent_data in list(self.live_view.agents.items()): - if agent_id not in self._displayed_agents: - self._add_agent_node(agent_data) - self._displayed_agents.add(agent_id) - else: - self._update_agent_node(agent_id, agent_data) - - self._update_chat_view() - - self._update_agent_status_display() - - self._update_stats_display() - - self._update_vulnerabilities_panel() - - def _sync_agent_graph(self) -> None: - future = self._agent_graph_sync_future - if future is not None: - if not future.done(): - if self._scan_loop is not None and self._scan_loop.is_closed(): - future.cancel() - self._agent_graph_sync_future = None - else: - return - else: - self._agent_graph_sync_future = None - try: - parent_of, statuses, names, errors = future.result() - except Exception: - logger.exception("TUI agent graph sync failed") - else: - for agent_id, status in statuses.items(): - error = errors.get(agent_id) - self.live_view.upsert_agent( - agent_id, - name=names.get(agent_id, agent_id), - parent_id=parent_of.get(agent_id), - status=status, - error_message=error or "", - ) - if error: - if agent_id not in self._error_noted_agents: - self._error_noted_agents.add(agent_id) - self.live_view.record_agent_error(agent_id, error) - else: - self._error_noted_agents.discard(agent_id) - self._notify_budget_pause(statuses) - - if self._scan_loop is None or self._scan_loop.is_closed(): - return - - async def collect() -> tuple[ - dict[str, str | None], dict[str, Any], dict[str, str], dict[str, str] - ]: - return await self.coordinator.graph_snapshot() - - self._agent_graph_sync_future = asyncio.run_coroutine_threadsafe(collect(), self._scan_loop) - - def _notify_budget_pause(self, statuses: dict[str, Any]) -> None: - paused = any(status == "budget_paused" for status in statuses.values()) - if paused and not self._budget_pause_notified: - self._budget_pause_notified = True - self.notify( - "Budget limit reached \u2014 agents paused. Send a message to continue " - "(this extends the budget), or ctrl-q to quit.", - severity="warning", - timeout=15, - ) - elif not paused: - self._budget_pause_notified = False - - def _update_agent_node(self, agent_id: str, agent_data: dict[str, Any]) -> bool: - if agent_id not in self.agent_nodes: - return False - - try: - agent_node = self.agent_nodes[agent_id] - agent_name_raw = agent_data.get("name", "Agent") - status = agent_data.get("status", "running") - - status_indicators = { - "running": "βšͺ", - "waiting": "⏸", - "budget_paused": "⏸", - "completed": "🟒", - "failed": "πŸ”΄", - "crashed": "πŸ”΄", - "stopped": "β– ", - } - - status_icon = status_indicators.get(status, "β—‹") - vuln_count = self._agent_vulnerability_count(agent_id) - vuln_indicator = f" ({vuln_count})" if vuln_count > 0 else "" - agent_name = f"{status_icon} {agent_name_raw}{vuln_indicator}" - - if agent_node.label != agent_name: - agent_node.set_label(agent_name) - return True - - except (KeyError, AttributeError, ValueError) as e: - logger.warning(f"Failed to update agent node label: {e}") - - return False - - def _get_chat_content( - self, - ) -> tuple[Any, str | None]: - if not self.selected_agent_id: - return self._get_chat_placeholder_content( - f"{self._startup_status}...", f"placeholder-no-agent-{self._startup_status_step}" - ) - - events = self._gather_agent_events(self.selected_agent_id) - - if not events: - return self._get_chat_placeholder_content( - "Starting agent...", "placeholder-no-activity" - ) - - current_event_ids = [f"{e['id']}:{e.get('version', 0)}" for e in events] - if current_event_ids == self._displayed_events: - return None, None - - self._displayed_events = current_event_ids - return self._get_rendered_events_content(events), "chat-content" - - def _update_chat_view(self) -> None: - if len(self.screen_stack) > 1 or self.show_splash or not self.is_mounted: - return - - try: - chat_history = self.query_one("#chat_history", VerticalScroll) - except (ValueError, Exception): - return - - if not self._is_widget_safe(chat_history): - return - - try: - is_at_bottom = chat_history.scroll_y >= chat_history.max_scroll_y - except (AttributeError, ValueError): - is_at_bottom = True - - content, css_class = self._get_chat_content() - if content is None: - return - - chat_display = self.query_one("#chat_display", Static) - self._safe_widget_operation(chat_display.update, content) - chat_display.set_classes(css_class) - - if is_at_bottom and not self._pending_scroll_end: - self._pending_scroll_end = True - self.call_later(self._do_scroll_end, chat_history) - - def _do_scroll_end(self, chat_history: VerticalScroll) -> None: - self._pending_scroll_end = False - try: - chat_history.scroll_end(animate=False) - except Exception: - logger.debug("Failed to scroll chat to end", exc_info=True) - - def _get_chat_placeholder_content( - self, message: str, placeholder_class: str - ) -> tuple[Text, str]: - self._displayed_events = [placeholder_class] - text = Text() - text.append(message) - return text, f"chat-placeholder {placeholder_class}" - - @staticmethod - def _merge_renderables(renderables: list[Any]) -> Text: - """Merge renderables into a single Text for mouse text selection support.""" - combined = Text() - for i, item in enumerate(renderables): - if i > 0: - combined.append("\n") - StrixTUIApp._append_renderable(combined, item) - return StrixTUIApp._sanitize_text(combined) - - @staticmethod - def _sanitize_text(text: Text) -> Text: - """Clamp spans so Rich/Textual can't crash on malformed offsets.""" - plain = text.plain - text_length = len(plain) - sanitized_spans: list[Span] = [] - - for span in text.spans: - start = max(0, min(span.start, text_length)) - end = max(0, min(span.end, text_length)) - if end > start: - sanitized_spans.append(Span(start, end, span.style)) - - return Text( - plain, - style=text.style, - justify=text.justify, - overflow=text.overflow, - no_wrap=text.no_wrap, - end=text.end, - tab_size=text.tab_size, - spans=sanitized_spans, - ) - - @staticmethod - def _append_renderable(combined: Text, item: Any) -> None: - """Recursively append a renderable's text content to a combined Text.""" - if isinstance(item, Text): - combined.append_text(StrixTUIApp._sanitize_text(item)) - elif isinstance(item, Group): - for j, sub in enumerate(item.renderables): - if j > 0: - combined.append("\n") - StrixTUIApp._append_renderable(combined, sub) - else: - inner = getattr(item, "content", None) or getattr(item, "renderable", None) - if inner is not None: - StrixTUIApp._append_renderable(combined, inner) - else: - combined.append(str(item)) - - def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> Any: - renderables: list[Any] = [] - - if not events: - return Text() - - for event in events: - content: Any = None - - if event["type"] == "chat": - content = self._render_chat_content(event["data"]) - elif event["type"] == "tool": - content = render_tool_widget(event["data"]) - - if content: - if renderables: - renderables.append(Text("")) - renderables.append(content) - - if not renderables: - return Text() - - if len(renderables) == 1 and isinstance(renderables[0], Text): - return self._sanitize_text(renderables[0]) - - return self._merge_renderables(renderables) - - def _get_status_display_content( - self, agent_id: str, agent_data: dict[str, Any] - ) -> tuple[Text | None, Text, bool]: - status = agent_data.get("status", "running") - - def keymap_styled(keys: list[tuple[str, str]]) -> Text: - t = Text() - for i, (key, action) in enumerate(keys): - if i > 0: - t.append(" Β· ", style="dim") - t.append(key, style="white") - t.append(" ", style="dim") - t.append(action, style="dim") - return t - - simple_statuses: dict[str, tuple[str, str]] = { - "stopped": ("Agent stopped", ""), - "completed": ("Agent completed", ""), - } - - if status in simple_statuses: - msg, _ = simple_statuses[status] - text = Text() - text.append(msg) - return (text, Text(), False) - - if status in {"failed", "crashed"}: - error_msg = agent_data.get("error_message", "") - text = Text() - text.append(error_msg or "Agent failed", style="red") - text.append(" Β· ", style="dim") - text.append("Send message to resume", style="dim") - self._stop_dot_animation() - return (text, Text(), False) - - if status in {"waiting", "budget_paused"}: - text = Text() - keymap = Text() - if status == "budget_paused": - text.append("Budget limit reached", style="yellow") - text.append(" \u00b7 ", style="dim") - text.append("Send a message to continue", style="dim") - keymap = keymap_styled([("ctrl-q", "quit")]) - else: - error_msg = agent_data.get("error_message") or "" - if error_msg: - text.append(error_msg, style="red") - text.append(" \u00b7 ", style="dim") - text.append("Send message to resume", style="dim") - return (text, keymap, False) - - if status == "running": - if self._agent_has_real_activity(agent_id): - animated_text = Text() - animated_text.append_text(self._get_sweep_animation(self._sweep_colors)) - animated_text.append("esc", style="white") - animated_text.append(" ", style="dim") - animated_text.append("stop", style="dim") - return (animated_text, keymap_styled([("ctrl-q", "quit")]), True) - animated_text = self._get_animated_verb_text(agent_id, "Initializing") - return (animated_text, keymap_styled([("ctrl-q", "quit")]), True) - - return (None, Text(), False) - - def _update_agent_status_display(self) -> None: - try: - status_display = self.query_one("#agent_status_display", Horizontal) - status_text = self.query_one("#status_text", Static) - keymap_indicator = self.query_one("#keymap_indicator", Static) - except (ValueError, Exception): - return - - widgets = [status_display, status_text, keymap_indicator] - if not all(self._is_widget_safe(w) for w in widgets): - return - - if not self.selected_agent_id: - self._safe_widget_operation(status_display.add_class, "hidden") - return - - try: - agent_data = self.live_view.agents[self.selected_agent_id] - content, keymap, should_animate = self._get_status_display_content( - self.selected_agent_id, agent_data - ) - - if not content: - self._safe_widget_operation(status_display.add_class, "hidden") - return - - self._safe_widget_operation(status_text.update, content) - self._safe_widget_operation(keymap_indicator.update, keymap) - self._safe_widget_operation(status_display.remove_class, "hidden") - - if should_animate: - self._start_dot_animation() - - except (KeyError, Exception): - self._safe_widget_operation(status_display.add_class, "hidden") - - def _update_stats_display(self) -> None: - try: - stats_display = self.query_one("#stats_display", Static) - except (ValueError, Exception): - return - - if not self._is_widget_safe(stats_display): - return - - if self.screen.selections: - return - - stats_content = Text() - - stats_text = build_tui_stats_text(self.report_state) - if stats_text: - stats_content.append(stats_text) - - version = get_package_version() - stats_content.append(f"\nv{version}", style="white") - - self._safe_widget_operation(stats_display.update, stats_content) - - def _update_vulnerabilities_panel(self) -> None: - """Update the vulnerabilities panel with current vulnerability data.""" - try: - vuln_panel = self.query_one("#vulnerabilities_panel", VulnerabilitiesPanel) - except (ValueError, Exception): - return - - if not self._is_widget_safe(vuln_panel): - return - - vulnerabilities = self.report_state.vulnerability_reports - - if not vulnerabilities: - self._safe_widget_operation(vuln_panel.add_class, "hidden") - return - - enriched_vulns = [] - for vuln in vulnerabilities: - enriched = dict(vuln) - agent_name = enriched.get("agent_name") - agent_id = enriched.get("agent_id") - if not agent_name and isinstance(agent_id, str): - agent_name = self._get_agent_name(agent_id) - if agent_name: - enriched["agent_name"] = agent_name - enriched_vulns.append(enriched) - - self._safe_widget_operation(vuln_panel.remove_class, "hidden") - vuln_panel.update_vulnerabilities(enriched_vulns) - - def _get_sweep_animation(self, color_palette: list[str]) -> Text: - text = Text() - num_squares = self._sweep_num_squares - num_colors = len(color_palette) - - offset = num_colors - 1 - max_pos = (num_squares - 1) + offset - total_range = max_pos + offset - cycle_length = total_range * 2 - frame_in_cycle = self._spinner_frame_index % cycle_length - - wave_pos = total_range - abs(total_range - frame_in_cycle) - sweep_pos = wave_pos - offset - - dot_color = "#0a3d1f" - - for i in range(num_squares): - dist = abs(i - sweep_pos) - color_idx = max(0, num_colors - 1 - dist) - - if color_idx == 0: - text.append("Β·", style=Style(color=dot_color)) - else: - color = color_palette[color_idx] - text.append("β–ͺ", style=Style(color=color)) - - text.append(" ") - return text - - def _get_animated_verb_text(self, agent_id: str, verb: str) -> Text: # noqa: ARG002 - text = Text() - sweep = self._get_sweep_animation(self._sweep_colors) - text.append_text(sweep) - parts = verb.split(" ", 1) - text.append(parts[0], style="white") - if len(parts) > 1: - text.append(" ", style="dim") - text.append(parts[1], style="dim") - return text - - def _start_dot_animation(self) -> None: - if self._dot_animation_timer is None: - self._dot_animation_timer = self.set_interval(0.06, self._animate_dots) - - def _stop_dot_animation(self) -> None: - if self._dot_animation_timer is not None: - self._dot_animation_timer.stop() - self._dot_animation_timer = None - - def _animate_dots(self) -> None: - has_active_agents = False - - if self.selected_agent_id and self.selected_agent_id in self.live_view.agents: - agent_data = self.live_view.agents[self.selected_agent_id] - status = agent_data.get("status", "running") - if status in ["running", "waiting"]: - has_active_agents = True - num_colors = len(self._sweep_colors) - offset = num_colors - 1 - max_pos = (self._sweep_num_squares - 1) + offset - total_range = max_pos + offset - cycle_length = total_range * 2 - self._spinner_frame_index = (self._spinner_frame_index + 1) % cycle_length - self._update_agent_status_display() - - if not has_active_agents: - has_active_agents = any( - agent_data.get("status", "running") in ["running", "waiting"] - for agent_data in self.live_view.agents.values() - ) - - if not has_active_agents: - self._stop_dot_animation() - self._spinner_frame_index = 0 - - def _agent_has_real_activity(self, agent_id: str) -> bool: - return self.live_view.has_events_for_agent(agent_id) - - def _agent_vulnerability_count(self, agent_id: str) -> int: - return sum( - 1 - for vuln in self.report_state.vulnerability_reports - if vuln.get("agent_id") == agent_id - ) - - def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]: - events = self.live_view.events_for_agent(agent_id) - events.sort(key=lambda e: (e["timestamp"], e["id"])) - return events - - def watch_selected_agent_id(self, _agent_id: str | None) -> None: - if len(self.screen_stack) > 1 or self.show_splash: - return - - if not self.is_mounted: - return - - self._displayed_events.clear() - - self.call_later(self._update_chat_view) - self._update_agent_status_display() - - def _start_scan_thread(self) -> None: - def scan_target() -> None: - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - self._scan_loop = loop - - try: - if not self._scan_stop_event.is_set(): - image = load_settings().runtime.image or "strix-sandbox:latest" - loop.run_until_complete( - run_strix_scan( - scan_config=self.scan_config, - scan_id=self.scan_config["run_name"], - image=str(image), - local_sources=getattr(self.args, "local_sources", None) or [], - coordinator=self.coordinator, - interactive=True, - max_budget_usd=getattr(self.args, "max_budget_usd", None), - max_turns=getattr(self.args, "max_turns", DEFAULT_MAX_TURNS), - event_sink=self._capture_sdk_event, - status_sink=self._capture_startup_status, - ), - ) - - except (KeyboardInterrupt, asyncio.CancelledError): - logger.info("Scan interrupted by user") - except BudgetExceededError: - logger.info("Scan stopped: --max-budget limit reached") - except (ConnectionError, TimeoutError) as e: - logging.exception("Network error during scan") - self._scan_error = e - except RuntimeError as e: - logging.exception("Runtime error during scan") - self._scan_error = e - except Exception as e: - logging.exception("Unexpected error during scan") - self._scan_error = e - finally: - with contextlib.suppress(Exception): - loop.run_until_complete( - session_manager.cleanup(self.scan_config["run_name"]), - ) - loop.close() - self._scan_completed.set() - - except Exception: - logging.exception("Error setting up scan thread") - self._scan_completed.set() - - self._scan_thread = threading.Thread(target=scan_target, daemon=True) - self._scan_thread.start() - - def _capture_startup_status(self, phase: str) -> None: - try: - self.call_from_thread(self._record_startup_status, phase) - except RuntimeError: - self._record_startup_status(phase) - - def _record_startup_status(self, phase: str) -> None: - self._startup_status = phase - self._startup_status_step += 1 - if not self.show_splash and not self.selected_agent_id: - self.call_later(self._update_chat_view) - - def _capture_sdk_event(self, agent_id: str, event: Any) -> None: - try: - self.call_from_thread(self._record_sdk_event, agent_id, event) - except RuntimeError: - self._record_sdk_event(agent_id, event) - - def _record_sdk_event(self, agent_id: str, event: Any) -> None: - self.live_view.ingest_sdk_event(agent_id, event) - - def _add_agent_node(self, agent_data: dict[str, Any]) -> None: - if len(self.screen_stack) > 1 or self.show_splash: - return - - if not self.is_mounted: - return - - agent_id = agent_data["id"] - parent_id = agent_data.get("parent_id") - status = agent_data.get("status", "running") - - try: - agents_tree = self.query_one("#agents_tree", Tree) - except (ValueError, Exception): - return - - agent_name_raw = agent_data.get("name", "Agent") - - status_indicators = { - "running": "βšͺ", - "waiting": "⏸", - "budget_paused": "⏸", - "completed": "🟒", - "failed": "πŸ”΄", - "crashed": "πŸ”΄", - "stopped": "β– ", - } - - status_icon = status_indicators.get(status, "β—‹") - vuln_count = self._agent_vulnerability_count(agent_id) - vuln_indicator = f" ({vuln_count})" if vuln_count > 0 else "" - agent_name = f"{status_icon} {agent_name_raw}{vuln_indicator}" - - try: - if parent_id and parent_id in self.agent_nodes: - parent_node = self.agent_nodes[parent_id] - agent_node = parent_node.add( - agent_name, - data={"agent_id": agent_id}, - ) - parent_node.allow_expand = True - else: - agent_node = agents_tree.root.add( - agent_name, - data={"agent_id": agent_id}, - ) - - agent_node.allow_expand = False - agent_node.expand() - self.agent_nodes[agent_id] = agent_node - - if len(self.agent_nodes) == 1: - agents_tree.select_node(agent_node) - self.selected_agent_id = agent_id - - self._reorganize_orphaned_agents(agent_id) - except (AttributeError, ValueError, RuntimeError) as e: - logger.warning(f"Failed to add agent node {agent_id}: {e}") - - def _copy_node_under(self, node_to_copy: TreeNode, new_parent: TreeNode) -> None: - agent_id = node_to_copy.data["agent_id"] - agent_data = self.live_view.agents.get(agent_id, {}) - agent_name_raw = agent_data.get("name", "Agent") - status = agent_data.get("status", "running") - - status_indicators = { - "running": "βšͺ", - "waiting": "⏸", - "budget_paused": "⏸", - "completed": "🟒", - "failed": "πŸ”΄", - "crashed": "πŸ”΄", - "stopped": "β– ", - } - - status_icon = status_indicators.get(status, "β—‹") - vuln_count = self._agent_vulnerability_count(agent_id) - vuln_indicator = f" ({vuln_count})" if vuln_count > 0 else "" - agent_name = f"{status_icon} {agent_name_raw}{vuln_indicator}" - - new_node = new_parent.add( - agent_name, - data=node_to_copy.data, - ) - new_node.allow_expand = node_to_copy.allow_expand - - self.agent_nodes[agent_id] = new_node - - for child in node_to_copy.children: - self._copy_node_under(child, new_node) - - if node_to_copy.is_expanded: - new_node.expand() - - def _reorganize_orphaned_agents(self, new_parent_id: str) -> None: - agents_to_move = [] - - for agent_id, agent_data in list(self.live_view.agents.items()): - if ( - agent_data.get("parent_id") == new_parent_id - and agent_id in self.agent_nodes - and agent_id != new_parent_id - ): - agents_to_move.append(agent_id) - - if not agents_to_move: - return - - parent_node = self.agent_nodes[new_parent_id] - - for child_agent_id in agents_to_move: - if child_agent_id in self.agent_nodes: - old_node = self.agent_nodes[child_agent_id] - - if old_node.parent is parent_node: - continue - - self._copy_node_under(old_node, parent_node) - - old_node.remove() - - parent_node.allow_expand = True - parent_node.expand() - - def _render_chat_content(self, msg_data: dict[str, Any]) -> Any: - role = msg_data.get("role") - content = msg_data.get("content", "") - metadata = msg_data.get("metadata", {}) - - if not content: - return None - - del metadata - if role == "user": - return UserMessageRenderer.render_simple(content) - - return AgentMessageRenderer.render_simple(content) - - @on(Tree.NodeHighlighted) # type: ignore[misc] - def handle_tree_highlight(self, event: Tree.NodeHighlighted) -> None: - if len(self.screen_stack) > 1 or self.show_splash: - return - - if not self.is_mounted: - return - - node = event.node - - try: - agents_tree = self.query_one("#agents_tree", Tree) - except (ValueError, Exception): - return - - if self.focused == agents_tree and node.data: - agent_id = node.data.get("agent_id") - if agent_id: - self.selected_agent_id = agent_id - - @on(Tree.NodeSelected) # type: ignore[misc] - def handle_tree_node_selected(self, event: Tree.NodeSelected) -> None: - if len(self.screen_stack) > 1 or self.show_splash: - return - - if not self.is_mounted: - return - - node = event.node - - if node.allow_expand: - if node.is_expanded: - node.collapse() - else: - node.expand() - - def _send_user_message(self, message: str) -> None: - if not self.selected_agent_id: - return - - logger.info( - "TUI: user message -> %s (len=%d)", - self.selected_agent_id, - len(message), - ) - target_agent_id = self.selected_agent_id - - submitted = send_user_message_to_agent( - coordinator=self.coordinator, - loop=self._scan_loop, - live_view=self.live_view, - target_agent_id=target_agent_id, - message=message, - ) - if not submitted: - if self._scan_completed.is_set(): - self.notify("The scan has ended; message was not sent", severity="warning") - else: - self.notify("Scan loop is not ready; message was not sent", severity="warning") - return - - self._displayed_events.clear() - self._update_chat_view() - - self.call_after_refresh(self._focus_chat_input) - - def _get_agent_name(self, agent_id: str) -> str: - try: - if agent_id in self.live_view.agents: - agent_name = self.live_view.agents[agent_id].get("name") - if isinstance(agent_name, str): - return agent_name - except (KeyError, AttributeError) as e: - logger.warning(f"Could not retrieve agent name for {agent_id}: {e}") - return "Unknown Agent" - - def action_toggle_help(self) -> None: - if self.show_splash or not self.is_mounted: - return - - try: - self.query_one("#main_container") - except (ValueError, Exception): - return - - if isinstance(self.screen, HelpScreen): - self.pop_screen() - return - - if len(self.screen_stack) > 1: - return - - self.push_screen(HelpScreen()) - - async def action_request_quit(self) -> None: - if self.show_splash or not self.is_mounted: - await self.action_custom_quit() - return - - if len(self.screen_stack) > 1: - return - - try: - self.query_one("#main_container") - except (ValueError, Exception): - await self.action_custom_quit() - return - - self.push_screen(QuitScreen()) - - def action_stop_selected_agent(self) -> None: - if self.show_splash or not self.is_mounted: - return - - if len(self.screen_stack) > 1: - self.pop_screen() - return - - if not self.selected_agent_id: - return - - agent_name, should_stop = self._validate_agent_for_stopping() - if not should_stop: - return - - try: - self.query_one("#main_container") - except (ValueError, Exception): - return - - self.push_screen(StopAgentScreen(agent_name, self.selected_agent_id)) - - def _validate_agent_for_stopping(self) -> tuple[str, bool]: - agent_name = "Unknown Agent" - - try: - if self.selected_agent_id in self.live_view.agents: - agent_data = self.live_view.agents[self.selected_agent_id] - agent_name = agent_data.get("name", "Unknown Agent") - - agent_status = agent_data.get("status", "running") - if agent_status not in ["running", "waiting"]: - return agent_name, False - - agent_events = self._gather_agent_events(self.selected_agent_id) - if not agent_events: - return agent_name, False - - return agent_name, True - - except (KeyError, AttributeError, ValueError) as e: - logger.warning(f"Failed to gather agent events: {e}") - - return agent_name, False - - def action_confirm_stop_agent(self, agent_id: str) -> None: - if self._scan_loop is None or self._scan_loop.is_closed(): - logger.warning("No active scan loop; cannot stop agent %s", agent_id) - return - logger.info("TUI: graceful stop requested for %s (cascade)", agent_id) - asyncio.run_coroutine_threadsafe( - self.coordinator.cancel_descendants_graceful(agent_id), - self._scan_loop, - ) - - async def action_custom_quit(self) -> None: - self._fire_sandbox_cleanup() - self._shutdown_viewer() - - if self._scan_thread and self._scan_thread.is_alive(): - self._scan_stop_event.set() - - self.report_state.cleanup() - - self.exit() - - def _viewer_cta_markup(self, url: str | None = None) -> str: - if url: - return f"[@click=app.open_viewer][#22c55e]● Viewer running[/][/]\n[dim]{url}[/]" - return "[@click=app.open_viewer]β–Ά Watch live in browser[/]" - - def _set_viewer_cta(self, markup: str) -> None: - with contextlib.suppress(Exception): - self.query_one("#viewer_cta", Static).update(markup) - - def action_open_viewer(self) -> None: - if self._viewer_url: - with contextlib.suppress(Exception): - webbrowser.open(self._viewer_url) - return - try: - from strix.interface.viewer.server import authorized_url, bundle_is_built, serve - - if not bundle_is_built(): - self._set_viewer_cta("[#eab308]Viewer UI not built[/]") - return - run_dir = self.report_state.get_run_dir() - - def _viewer_steer(agent_id: str, message: str) -> bool: - # Reuse the exact TUI delivery path, but target the agent the - # web graph selected (not the TUI's current selection). - return send_user_message_to_agent( - coordinator=self.coordinator, - loop=self._scan_loop, - live_view=self.live_view, - target_agent_id=agent_id, - message=message, - ) - - httpd, url, token = serve(run_dir, open_browser=True, steer_handler=_viewer_steer) - except Exception: - logger.debug("failed to start local viewer", exc_info=True) - self._set_viewer_cta("[red]Viewer failed to start[/]") - return - self._viewer_httpd = httpd - # Store the tokened URL so reopening the CTA re-authorizes the browser - # (this viewer carries a steer handler, so the session is required). - self._viewer_url = authorized_url(url, token) - self._set_viewer_cta(self._viewer_cta_markup(self._viewer_url)) - - with contextlib.suppress(Exception): - from strix.telemetry import posthog - - live = self.report_state.run_record.get("status") not in { - "completed", - "stopped", - "failed", - "interrupted", - } - posthog.viewer_opened(source="tui", live=live) - - def _shutdown_viewer(self) -> None: - httpd = self._viewer_httpd - if httpd is None: - return - self._viewer_httpd = None - with contextlib.suppress(Exception): - httpd.shutdown() - httpd.server_close() - - def _fire_sandbox_cleanup(self) -> None: - self.coordinator.mark_shutting_down() - loop = self._scan_loop - if loop is None or loop.is_closed(): - return - run_name = self.scan_config.get("run_name") - if not run_name: - return - with contextlib.suppress(Exception): - asyncio.run_coroutine_threadsafe(session_manager.cleanup(run_name), loop) - - def _is_widget_safe(self, widget: Any) -> bool: - try: - _ = widget.screen - except (AttributeError, ValueError, Exception): - return False - else: - return bool(widget.is_mounted) - - def _safe_widget_operation( - self, operation: Callable[..., Any], *args: Any, **kwargs: Any - ) -> bool: - try: - operation(*args, **kwargs) - except (AttributeError, ValueError, Exception): - return False - else: - return True - - def on_resize(self, event: events.Resize) -> None: - if self.show_splash or not self.is_mounted: - return - - try: - sidebar = self.query_one("#sidebar", Vertical) - chat_area = self.query_one("#chat_area_container", Vertical) - except (ValueError, Exception): - return - - if event.size.width < self.SIDEBAR_MIN_WIDTH: - sidebar.add_class("-hidden") - chat_area.add_class("-full-width") - else: - sidebar.remove_class("-hidden") - chat_area.remove_class("-full-width") - - def on_mouse_up(self, _event: events.MouseUp) -> None: - self.set_timer(0.05, self._auto_copy_selection) - - _ICON_PREFIXES: ClassVar[tuple[str, ...]] = ( - "🐞 ", - "🌐 ", - "πŸ“‹ ", - "🧠 ", - "β—† ", - "β—‡ ", - "β—ˆ ", - "β†’ ", - "β—‹ ", - "● ", - "βœ“ ", - "βœ— ", - "⚠ ", - "▍ ", - "▍", - "┃ ", - "β€’ ", - ">_ ", - " ", - "<~> ", - "[ ] ", - "[~] ", - "[β€’] ", - ) - - _DECORATIVE_LINES: ClassVar[frozenset[str]] = frozenset( - { - "● In progress...", - "βœ“ Done", - "βœ— Failed", - "βœ— Error", - "β—‹ Unknown", - } - ) - - @staticmethod - def _clean_copied_text(text: str) -> str: - lines = text.split("\n") - cleaned: list[str] = [] - for line in lines: - stripped = line.lstrip() - if stripped in StrixTUIApp._DECORATIVE_LINES: - continue - if stripped and all(c == "─" for c in stripped): - continue - out = line - for prefix in StrixTUIApp._ICON_PREFIXES: - if stripped.startswith(prefix): - leading = line[: len(line) - len(line.lstrip())] - out = leading + stripped[len(prefix) :] - break - cleaned.append(out) - return "\n".join(cleaned) - - def _auto_copy_selection(self) -> None: - copied = False - - try: - if self.screen.selections: - selected = self.screen.get_selected_text() - self.screen.clear_selection() - if selected and selected.strip(): - cleaned = self._clean_copied_text(selected) - self.copy_to_clipboard(cleaned if cleaned.strip() else selected) - copied = True - except Exception: - logger.debug("Failed to copy screen selection", exc_info=True) - - if not copied: - try: - chat_input = self.query_one("#chat_input", ChatTextArea) - selected = chat_input.selected_text - if selected and selected.strip(): - self.copy_to_clipboard(selected) - chat_input.move_cursor(chat_input.cursor_location) - copied = True - except Exception: - logger.debug("Failed to copy chat input selection", exc_info=True) - - if copied: - self.notify("Copied to clipboard", timeout=2) - - -async def run_tui(args: argparse.Namespace) -> None: - app = StrixTUIApp(args) - await app.run_async() - if app._scan_error is not None: - raise app._scan_error diff --git a/strix/interface/tui/backend/__init__.py b/strix/interface/tui/backend/__init__.py new file mode 100644 index 00000000..58120d2a --- /dev/null +++ b/strix/interface/tui/backend/__init__.py @@ -0,0 +1,7 @@ +"""Backend bridge for external TUI clients.""" + +from strix.interface.tui.backend.controller import TuiController +from strix.interface.tui.backend.server import TuiBackendServer + + +__all__ = ["TuiBackendServer", "TuiController"] diff --git a/strix/interface/tui/backend/controller.py b/strix/interface/tui/backend/controller.py new file mode 100644 index 00000000..3784604d --- /dev/null +++ b/strix/interface/tui/backend/controller.py @@ -0,0 +1,497 @@ +"""UI-independent state and command controller for interactive Strix clients.""" + +from __future__ import annotations + +import asyncio +import contextlib +import math +import webbrowser +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from strix.config import load_settings +from strix.config.models import is_recommended_or_frontier_model +from strix.config.settings import DEFAULT_MAX_TURNS +from strix.interface.tui.backend.live_view import TuiLiveView +from strix.interface.tui.backend.projection import ( + MAX_TERMINAL_EVENTS, + MAX_TERMINAL_VULNERABILITIES, + SCAN_MODES, + SCOPE_MODES, + bounded_state_projection, + collection_item_projection, + sanitize_terminal_text, + terminal_projection, +) +from strix.interface.utils import is_subscription_run + + +if TYPE_CHECKING: + import argparse + + from strix.report.state import ReportState + + +_STOPPABLE_AGENT_STATUSES = frozenset({"running", "waiting", "budget_paused"}) + +ChangeCallback = Callable[[], None] +StartCallback = Callable[[bool], Awaitable[None]] +QuitCallback = Callable[[], Awaitable[None]] + + +class TuiController: + """Own setup state and expose serializable scan state to any TUI.""" + + def __init__( + self, + args: argparse.Namespace, + *, + live_view: TuiLiveView | None = None, + coordinator: Any = None, + report_state: ReportState | None = None, + on_start: StartCallback | None = None, + on_quit: QuitCallback | None = None, + on_change: ChangeCallback | None = None, + ) -> None: + self.args = args + self.live_view = live_view or TuiLiveView() + self.coordinator = coordinator + self.report_state = report_state + self.scan_loop: asyncio.AbstractEventLoop | None = None + self.setup_mode = bool(args.needs_setup) + self.scan_started = not self.setup_mode + self._start_in_progress = False + self.scan_state = "setup" if self.setup_mode else "running" + self.targets = [ + str(target["original"]) + for target in args.targets_info + if isinstance(target, dict) and target.get("original") + ] + instruction = args.instruction + self.instruction = instruction.strip() if isinstance(instruction, str) else "" + requested_scan_mode = str(args.scan_mode) + self.scan_mode = requested_scan_mode if requested_scan_mode in SCAN_MODES else "deep" + raw_budget = args.max_budget_usd + self.max_budget_usd = ( + float(raw_budget) + if isinstance(raw_budget, int | float) + and not isinstance(raw_budget, bool) + and math.isfinite(float(raw_budget)) + and raw_budget > 0 + else None + ) + raw_turns = args.max_turns + self.max_turns = ( + raw_turns + if isinstance(raw_turns, int) and not isinstance(raw_turns, bool) and raw_turns > 0 + else DEFAULT_MAX_TURNS + ) + requested_scope = str(args.scope_mode) + self.scope_mode = requested_scope if requested_scope in SCOPE_MODES else "auto" + raw_diff_base = args.diff_base + self.diff_base = raw_diff_base.strip() if isinstance(raw_diff_base, str) else None + # Host directory mounted for the agent to work in when the scan has no + # target, set only once the user confirms it. It is a workspace, not a + # target: it carries no scan scope, and the instruction is the only + # source of truth for what to do. + self.workspace_mount: str | None = None + # A target-less launch enters the live view and asks there before + # anything is prepared; this holds the directory awaiting that answer. + self.pending_workspace_mount: str | None = None + self._pending_verify = True + self.messages: list[dict[str, str]] = [] + self._next_message_id = 1 + self.error: str | None = None + self.viewer_status = "idle" + self.viewer_url: str | None = None + self._viewer_httpd: Any = None + self._on_start = on_start + self._on_quit = on_quit + self._on_change = on_change + + def set_change_callback(self, callback: ChangeCallback) -> None: + self._on_change = callback + + def notify_changed(self) -> None: + if self._on_change is not None: + self._on_change() + + def set_runtime( + self, + *, + report_state: ReportState | None = None, + scan_loop: asyncio.AbstractEventLoop | None = None, + ) -> None: + if report_state is not None: + self.report_state = report_state + if scan_loop is not None: + self.scan_loop = scan_loop + + def begin_preparation(self) -> None: + """Mark a directly-launched run as preparing behind the live TUI.""" + self.scan_state = "preparing" + self.notify_changed() + + def fail_preparation(self, detail: str) -> None: + self.scan_state = "failed" + self.error = detail + self.notify_changed() + + def enter_setup(self) -> None: + """Return a session to the start screen, e.g. on a declined mount.""" + self.setup_mode = True + self.scan_started = False + self.scan_state = "setup" + self.notify_changed() + + def add_message(self, text: str, level: str = "info") -> None: + self._append_message(text, level) + self.notify_changed() + + def _append_message(self, text: str, level: str) -> None: + self.messages.append( + { + "id": f"message-{self._next_message_id}", + "text": sanitize_terminal_text(text), + "level": sanitize_terminal_text(level), + } + ) + self._next_message_id += 1 + self.messages = self.messages[-200:] + + def snapshot(self) -> dict[str, Any]: + """Return small mutable state; histories are streamed as collections.""" + model = "" + with contextlib.suppress(Exception): + model = (load_settings().llm.model or "").strip() + usage: dict[str, Any] = {} + if self.report_state is not None: + usage = dict(self.report_state.get_total_llm_usage()) + subscription = False + with contextlib.suppress(Exception): + subscription = is_subscription_run(self.report_state) + model_warning = "" + if model and not is_recommended_or_frontier_model(model): + model_warning = ( + f"{model} is not a recommended frontier model; pentest quality could be degraded" + ) + state = { + "setup_mode": self.setup_mode, + "scan_started": self.scan_started, + "scan_state": self.scan_state, + "targets": [ + terminal_projection(target, max_string=128) for target in self.targets[:16] + ], + "target_count": len(self.targets), + "working_dir": str(Path.cwd()), + "pending_mount": self.pending_workspace_mount or "", + "instruction": terminal_projection(self.instruction, max_string=2 * 1024), + "scan_mode": self.scan_mode, + "max_budget_usd": self.max_budget_usd, + "max_turns": self.max_turns, + "scope_mode": self.scope_mode, + "diff_base": terminal_projection(self.diff_base, max_string=256), + "model": terminal_projection(model, max_string=256), + "model_warning": terminal_projection(model_warning, max_string=512), + "caido_url": terminal_projection( + getattr(self.report_state, "caido_url", None), max_string=1024 + ), + "messages": [ + { + "id": str(message.get("id", ""))[:64], + "text": terminal_projection(message.get("text", ""), max_string=256), + "level": str(message.get("level", "info"))[:32], + } + for message in self.messages[-10:] + ], + "usage": terminal_projection(usage, max_string=256, max_items=20), + "subscription": subscription, + "viewer_status": self.viewer_status, + "viewer_url": terminal_projection(self.viewer_url, max_string=1024), + "error": terminal_projection(self.error, max_string=2 * 1024), + } + return bounded_state_projection(state) + + def collection(self, name: str) -> list[dict[str, Any]]: + """Return one bounded terminal projection with stable item identities.""" + if name == "agents": + return [ + { + key: terminal_projection(agent.get(key), max_string=256, max_items=5) + for key in ( + "id", + "name", + "parent_id", + "status", + "error_message", + "created_at", + "updated_at", + ) + if key in agent + } + for agent in self.live_view.agents.values() + ] + if name == "events": + return [collection_item_projection(event) for event in self.live_view.events] + if name == "vulnerabilities": + reports = ( + self.report_state.vulnerability_reports if self.report_state is not None else [] + )[-MAX_TERMINAL_VULNERABILITIES:] + result: list[dict[str, Any]] = [] + for index, report in enumerate(reports): + projected = collection_item_projection(report) + report_id = projected.get("id") + if not isinstance(report_id, str) or not report_id: + projected["id"] = f"vulnerability-{index}" + result.append(projected) + return result + raise ValueError(f"Unknown collection: {name}") + + def collection_snapshot(self, name: str) -> tuple[int | None, list[dict[str, Any]]]: + """Return a collection cursor and complete bounded projection.""" + if name == "events": + cursor, events = self.live_view.event_snapshot(limit=MAX_TERMINAL_EVENTS) + return cursor, [collection_item_projection(event) for event in events] + return None, self.collection(name) + + def collection_changes( + self, + name: str, + cursor: int, + ) -> tuple[int, list[dict[str, Any]]]: + """Return event upserts since a monotonic source cursor.""" + if name != "events": + raise ValueError(f"Collection {name!r} does not expose incremental changes") + next_cursor, events = self.live_view.event_changes_since(cursor) + return next_cursor, [ + collection_item_projection(event) for event in events[-MAX_TERMINAL_EVENTS:] + ] + + async def handle(self, command: str, payload: dict[str, Any]) -> dict[str, Any]: + handlers = { + "setup.add_target": self._add_target, + "setup.set_instruction": self._set_instruction, + "setup.start": self._start, + "setup.confirm_mount": self._confirm_mount, + "agent.send_message": self._send_message, + "agent.stop": self._stop_agent, + "viewer.open": self._open_viewer, + "app.quit": self._quit, + } + handler = handlers.get(command) + if handler is None: + raise ValueError(f"Unknown command: {command}") + result = await handler(payload) + self.notify_changed() + return result + + async def _add_target(self, payload: dict[str, Any]) -> dict[str, Any]: + self._require_setup_mutable() + target = self._required_string(payload, "target") + if target not in self.targets: + self.targets.append(target) + return {"target": target, "total": len(self.targets)} + + async def _set_instruction(self, payload: dict[str, Any]) -> dict[str, Any]: + self._require_setup_mutable() + instruction = payload.get("instruction", "") + if not isinstance(instruction, str): + raise TypeError("instruction must be a string") + self.instruction = instruction.strip() + return {"instruction": self.instruction} + + async def _start(self, payload: dict[str, Any]) -> dict[str, Any]: + if self.scan_started or self._start_in_progress: + raise RuntimeError("Scan is already starting or running") + # A bare prompt launches optimistically, like a coding agent: it skips + # the network model preflight and surfaces any model error live. A named + # target keeps the preflight so a real scan does not commit blind. + verify = payload.get("verify", True) + if not isinstance(verify, bool): + raise TypeError("verify must be a boolean") + # Launching with no target mounts the working directory, so it requires + # the user's explicit confirmation rather than happening silently. + mount_working_dir = payload.get("mount_working_dir", False) + if not isinstance(mount_working_dir, bool): + raise TypeError("mount_working_dir must be a boolean") + model = (load_settings().llm.model or "").strip() + if not model: + raise ValueError("No model configured. Set STRIX_LLM first.") + if self._on_start is None: + raise RuntimeError("Scan start is unavailable") + if not self.targets: + if not mount_working_dir: + raise ValueError("No target set. Add a target first.") + # Mounting the working directory needs the user's confirmation, and + # that is asked in the live view. Enter it now and prepare nothing + # until the answer arrives, so declining leaves no run behind. + self.pending_workspace_mount = str(Path.cwd()) + self._pending_verify = verify + self.setup_mode = False + self.scan_started = True + self.scan_state = "preparing" + return {"started": True} + await self._begin_scan(verify) + return {"started": True} + + async def _begin_scan(self, verify: bool) -> None: + if self._on_start is None: + raise RuntimeError("Scan start is unavailable") + self._start_in_progress = True + try: + await self._on_start(verify) + finally: + self._start_in_progress = False + self.setup_mode = False + self.scan_started = True + self.scan_state = "running" + + async def _confirm_mount(self, payload: dict[str, Any]) -> dict[str, Any]: + """Answer the pending working-directory mount asked for in the live view.""" + mount = self.pending_workspace_mount + if mount is None: + raise RuntimeError("No mount confirmation is pending") + approved = payload.get("approved") + if not isinstance(approved, bool): + raise TypeError("approved must be a boolean") + self.pending_workspace_mount = None + if not approved: + # Nothing was prepared, so return to the start screen untouched. + self.workspace_mount = None + self.enter_setup() + return {"approved": False} + self.workspace_mount = mount + await self._begin_scan(self._pending_verify) + return {"approved": True} + + async def _send_message(self, payload: dict[str, Any]) -> dict[str, Any]: + agent_id = self._required_string(payload, "agent_id") + message = self._required_string(payload, "message") + if self.coordinator is None: + raise RuntimeError("Agent coordinator is unavailable") + if self.scan_loop is None or self.scan_loop.is_closed(): + raise RuntimeError("Scan loop is not ready") + self.live_view.record_user_message(agent_id, message) + if self.scan_loop is asyncio.get_running_loop(): + delivered = await self.coordinator.send( + agent_id, + {"from": "user", "content": message, "type": "instruction"}, + ) + else: + future = asyncio.run_coroutine_threadsafe( + self.coordinator.send( + agent_id, + {"from": "user", "content": message, "type": "instruction"}, + ), + self.scan_loop, + ) + delivered = await asyncio.wrap_future(future) + if not delivered: + raise RuntimeError("Message could not be delivered") + return {"sent": True} + + async def _stop_agent(self, payload: dict[str, Any]) -> dict[str, Any]: + agent_id = self._required_string(payload, "agent_id") + agent = self.live_view.agents.get(agent_id) + if agent is None: + raise ValueError(f"Unknown agent: {agent_id}") + status = str(agent.get("status", "")) + if status not in _STOPPABLE_AGENT_STATUSES: + raise RuntimeError(f"Agent '{agent_id}' cannot be stopped while {status or 'unknown'}") + if self.coordinator is None or self.scan_loop is None or self.scan_loop.is_closed(): + raise RuntimeError("Scan loop is not ready") + if self.scan_loop is asyncio.get_running_loop(): + accepted = await self.coordinator.cancel_descendants_graceful(agent_id) + else: + future = asyncio.run_coroutine_threadsafe( + self.coordinator.cancel_descendants_graceful(agent_id), self.scan_loop + ) + accepted = await asyncio.wrap_future(future) + if not accepted: + raise RuntimeError(f"Agent '{agent_id}' is no longer active") + return {"stopped": True} + + async def _open_viewer(self, _payload: dict[str, Any]) -> dict[str, Any]: + if self.viewer_url: + with contextlib.suppress(Exception): + webbrowser.open(self.viewer_url) + return {"status": "running", "url": self.viewer_url} + if self.report_state is None: + self.viewer_status = "failed" + return {"status": self.viewer_status, "error": "Scan output is not ready"} + try: + from strix.interface.tui.backend.messages import ( + send_user_message_to_agent, + ) + from strix.interface.viewer.server import ( + authorized_url, + bundle_is_built, + serve, + ) + + if not bundle_is_built(): + self.viewer_status = "unavailable" + return {"status": self.viewer_status, "error": "Viewer UI not built"} + + def steer(agent_id: str, message: str) -> bool: + return send_user_message_to_agent( + coordinator=self.coordinator, + loop=self.scan_loop, + live_view=self.live_view, + target_agent_id=agent_id, + message=message, + notify_changed=self.notify_changed, + wait_for_delivery=True, + ) + + httpd, url, token = serve( + self.report_state.get_run_dir(), + open_browser=True, + steer_handler=steer, + ) + self._viewer_httpd = httpd + self.viewer_url = authorized_url(url, token) + self.viewer_status = "running" + with contextlib.suppress(Exception): + from strix.telemetry import posthog + + live = self.report_state.run_record.get("status") not in { + "completed", + "stopped", + "failed", + "interrupted", + } + posthog.viewer_opened(source="tui", live=live) + except Exception: # noqa: BLE001 - viewer startup failures must not crash the TUI + self.viewer_status = "failed" + return {"status": self.viewer_status, "error": "Viewer failed to start"} + else: + return {"status": self.viewer_status, "url": self.viewer_url} + + def close_viewer(self) -> None: + httpd = self._viewer_httpd + if httpd is None: + return + self._viewer_httpd = None + with contextlib.suppress(Exception): + httpd.shutdown() + httpd.server_close() + + async def _quit(self, _payload: dict[str, Any]) -> dict[str, Any]: + self.close_viewer() + if self._on_quit is not None: + await self._on_quit() + self.scan_state = "stopped" + return {"quitting": True} + + @staticmethod + def _required_string(payload: dict[str, Any], name: str) -> str: + value = payload.get(name) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{name} must be a non-empty string") + return value.strip() + + def _require_setup_mutable(self) -> None: + if not self.setup_mode or self.scan_started or self._start_in_progress: + raise RuntimeError("Setup can no longer be changed after the scan starts") diff --git a/strix/interface/tui/backend/live_view.py b/strix/interface/tui/backend/live_view.py new file mode 100644 index 00000000..bc12c034 --- /dev/null +++ b/strix/interface/tui/backend/live_view.py @@ -0,0 +1,136 @@ +"""Go-TUI event projection layered on the shared base projection.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from strix.interface.tui.live_view import TuiLiveView as BaseLiveView + + +_MAX_LIVE_EVENTS = 10_000 + + +class TuiLiveView(BaseLiveView): + """Add protocol cursors and bounds on top of the shared projection state.""" + + def __init__(self) -> None: + super().__init__() + self._event_cursor = 0 + self._event_change_cursor: dict[str, int] = {} + self._events_by_id: dict[str, dict[str, Any]] = {} + + def upsert_agent( # type: ignore[override] + self, + agent_id: str, + *, + name: str | None = None, + parent_id: str | None = None, + status: str | None = None, + error_message: str | None = None, + ) -> bool: + now = datetime.now(UTC).isoformat() + current = self.agents.get(agent_id) + if current is None: + current = { + "id": agent_id, + "name": name or agent_id, + "parent_id": parent_id, + "status": status or "running", + "created_at": now, + "updated_at": now, + } + if error_message: + current["error_message"] = error_message + self.agents[agent_id] = current + return True + + changed = False + if name is not None and current.get("name") != name: + current["name"] = name + changed = True + if (parent_id is not None or "parent_id" not in current) and current.get( + "parent_id" + ) != parent_id: + current["parent_id"] = parent_id + changed = True + if status is not None and current.get("status") != status: + current["status"] = status + changed = True + if error_message and current.get("error_message") != error_message: + current["error_message"] = error_message + changed = True + if changed: + current["updated_at"] = now + return changed + + def _append_event( + self, + agent_id: str, + event_type: str, + data: dict[str, Any], + *, + timestamp: str | None = None, + ) -> dict[str, Any]: + event = super()._append_event( + agent_id, + event_type, + data, + timestamp=timestamp, + ) + self._events_by_id[event["id"]] = event + self._mark_event_changed(event) + if len(self.events) > _MAX_LIVE_EVENTS: + removed = self.events.pop(0) + removed_id = str(removed.get("id", "")) + self._events_by_id.pop(removed_id, None) + self._event_change_cursor.pop(removed_id, None) + self._open_assistant_event_by_agent = { + current_agent_id: current + for current_agent_id, current in self._open_assistant_event_by_agent.items() + if current is not removed + } + self._tool_event_by_agent_and_call_id = { + key: current + for key, current in self._tool_event_by_agent_and_call_id.items() + if current is not removed + } + return event + + def _bump_event( # type: ignore[override] + self, + event: dict[str, Any], + *, + timestamp: str | None = None, + ) -> None: + event["version"] = int(event.get("version", 0)) + 1 + event["timestamp"] = timestamp or datetime.now(UTC).isoformat() + self._mark_event_changed(event) + + def _mark_event_changed(self, event: dict[str, Any]) -> None: + event_id = event.get("id") + if not isinstance(event_id, str) or not event_id: + return + self._event_cursor += 1 + self._event_change_cursor[event_id] = self._event_cursor + + def event_snapshot(self, *, limit: int | None = None) -> tuple[int, list[dict[str, Any]]]: + events = self.events[-limit:] if limit is not None else self.events + return self._event_cursor, list(events) + + def event_changes_since(self, cursor: int) -> tuple[int, list[dict[str, Any]]]: + if cursor < 0 or cursor > self._event_cursor: + raise ValueError("event cursor is outside the available history") + changed_ids = sorted( + ( + (change_cursor, event_id) + for event_id, change_cursor in self._event_change_cursor.items() + if change_cursor > cursor + ) + ) + changed = [ + self._events_by_id[event_id] + for _change_cursor, event_id in changed_ids + if event_id in self._events_by_id + ] + return self._event_cursor, changed diff --git a/strix/interface/tui/backend/messages.py b/strix/interface/tui/backend/messages.py new file mode 100644 index 00000000..77bc8bf2 --- /dev/null +++ b/strix/interface/tui/backend/messages.py @@ -0,0 +1,61 @@ +"""Confirmed message delivery for non-Textual interactive clients.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + from collections.abc import Callable + + +logger = logging.getLogger(__name__) + + +def send_user_message_to_agent( + *, + coordinator: Any, + loop: asyncio.AbstractEventLoop | None, + live_view: Any, + target_agent_id: str, + message: str, + notify_changed: Callable[[], None] | None = None, + wait_for_delivery: bool = False, +) -> bool: + if loop is None or loop.is_closed(): + return False + + async def deliver() -> bool: + delivered = bool( + await coordinator.send( + target_agent_id, + {"from": "user", "content": message, "type": "instruction"}, + ) + ) + if delivered: + live_view.record_user_message(target_agent_id, message) + if notify_changed is not None: + notify_changed() + return delivered + + future = asyncio.run_coroutine_threadsafe(deliver(), loop) + if wait_for_delivery: + try: + return bool(future.result(timeout=10)) + except Exception: + logger.exception("TUI user message delivery failed") + return False + future.add_done_callback(_log_delivery_failure) + return True + + +def _log_delivery_failure(future: Any) -> None: + try: + delivered = bool(future.result()) + except Exception: + logger.exception("TUI user message delivery failed") + return + if not delivered: + logger.warning("TUI user message was not persisted to the SDK session") diff --git a/strix/interface/tui/backend/projection.py b/strix/interface/tui/backend/projection.py new file mode 100644 index 00000000..22fa957e --- /dev/null +++ b/strix/interface/tui/backend/projection.py @@ -0,0 +1,182 @@ +"""Wire-safe projections of runtime state for the TUI backend.""" + +from __future__ import annotations + +import json +import re +from typing import Any + + +SCAN_MODES = ("quick", "standard", "deep") +SCOPE_MODES = ("auto", "diff", "full") +MAX_PROJECTION_STRING = 64 * 1024 +MAX_IMAGE_DATA_URI_BYTES = 2 * 1024 * 1024 +MAX_COLLECTION_ITEM_BYTES = 512 * 1024 +MAX_TERMINAL_EVENTS = 5_000 +MAX_TERMINAL_VULNERABILITIES = 1_000 +STATE_TARGET_BYTES = 48 * 1024 +TERMINAL_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_][0-?]*[ -/]*[@-~]") + + +def sanitize_terminal_text(value: str) -> str: + without_escapes = TERMINAL_ESCAPE_RE.sub("", value) + return "".join( + character + for character in without_escapes + if character in "\n\t" or (ord(character) >= 32 and not 127 <= ord(character) <= 159) + ) + + +def terminal_projection( # noqa: PLR0911 + value: Any, + *, + max_string: int = MAX_PROJECTION_STRING, + max_items: int = 200, + depth: int = 0, +) -> Any: + """Copy and bound terminal-only data without changing durable history.""" + if isinstance(value, str): + if value.startswith("data:image/"): + if len(value) <= MAX_IMAGE_DATA_URI_BYTES: + return value + return "[image omitted from terminal projection]" + clean = sanitize_terminal_text(value) + if len(clean) <= max_string: + return clean + omitted = len(clean) - max_string + return f"{clean[:max_string]}\n...[{omitted} characters omitted from terminal projection]" + if value is None or isinstance(value, bool | int | float): + return value + if depth >= 8: + return "[nested value omitted from terminal projection]" + if isinstance(value, dict): + items = list(value.items()) + projected = { + sanitize_terminal_text(str(key)): terminal_projection( + item, + max_string=max_string, + max_items=max_items, + depth=depth + 1, + ) + for key, item in items[:max_items] + } + if len(items) > max_items: + projected["_projection_notice"] = ( + f"{len(items) - max_items} fields omitted from terminal projection" + ) + return projected + if isinstance(value, list | tuple): + projected_items = [ + terminal_projection( + item, + max_string=max_string, + max_items=max_items, + depth=depth + 1, + ) + for item in value[:max_items] + ] + if len(value) > max_items: + projected_items.append( + f"[{len(value) - max_items} items omitted from terminal projection]" + ) + return projected_items + return terminal_projection( + str(value), + max_string=max_string, + max_items=max_items, + depth=depth, + ) + + +def collection_item_projection(item: dict[str, Any]) -> dict[str, Any]: + # Image data URIs are exempt from string truncation, so grant them their + # own byte budget on top of the regular per-item budget. + item_budget = MAX_COLLECTION_ITEM_BYTES + MAX_IMAGE_DATA_URI_BYTES + projected = terminal_projection(item) + assert isinstance(projected, dict) + if len(json.dumps(projected, default=str, separators=(",", ":")).encode()) <= item_budget: + return projected + + projected = terminal_projection(item, max_string=8 * 1024, max_items=40) + assert isinstance(projected, dict) + projected["projection_truncated"] = True + if len(json.dumps(projected, default=str, separators=(",", ":")).encode()) <= item_budget: + return projected + + # Preserve identity and useful summary fields even for pathological nested + # tool output or finding evidence. + compact: dict[str, Any] = { + key: terminal_projection(item[key], max_string=8 * 1024, max_items=10) + for key in ( + "id", + "version", + "type", + "agent_id", + "timestamp", + "title", + "severity", + "description", + ) + if key in item + } + compact["projection_truncated"] = True + return compact + + +def bounded_state_projection(state: dict[str, Any]) -> dict[str, Any]: + """Keep mutable control state comfortably below the 64 KiB frame limit.""" + + def encoded_size(value: dict[str, Any]) -> int: + return len( + json.dumps(value, default=str, ensure_ascii=False, separators=(",", ":")).encode() + ) + + if encoded_size(state) <= STATE_TARGET_BYTES: + return state + + state["projection_truncated"] = True + state["targets"] = [ + terminal_projection(target, max_string=64) for target in state["targets"][:8] + ] + state["instruction"] = terminal_projection(state["instruction"], max_string=512) + state["messages"] = [ + { + **message, + "text": terminal_projection(message.get("text", ""), max_string=128), + } + for message in state["messages"][-5:] + ] + state["usage"] = {} + state["error"] = terminal_projection(state["error"], max_string=512) + state["model_warning"] = terminal_projection(state["model_warning"], max_string=256) + state["caido_url"] = terminal_projection(state["caido_url"], max_string=256) + state["viewer_url"] = terminal_projection(state["viewer_url"], max_string=256) + if encoded_size(state) <= STATE_TARGET_BYTES: + return state + + # Defensive final projection: use an explicit schema so future snapshot + # fields cannot silently bypass the aggregate byte budget. + return { + "setup_mode": state["setup_mode"], + "scan_started": state["scan_started"], + "scan_state": state["scan_state"], + "targets": state["targets"][:4], + "target_count": state["target_count"], + "instruction": terminal_projection(state["instruction"], max_string=128), + "scan_mode": state["scan_mode"], + "max_budget_usd": state["max_budget_usd"], + "max_turns": state["max_turns"], + "scope_mode": state["scope_mode"], + "diff_base": state["diff_base"], + "provider": state["provider"], + "model": state["model"], + "model_warning": "", + "caido_url": None, + "messages": [], + "usage": {}, + "subscription": state["subscription"], + "viewer_status": state["viewer_status"], + "viewer_url": None, + "error": terminal_projection(state["error"], max_string=256), + "projection_truncated": True, + } diff --git a/strix/interface/tui/backend/protocol.py b/strix/interface/tui/backend/protocol.py new file mode 100644 index 00000000..99da5823 --- /dev/null +++ b/strix/interface/tui/backend/protocol.py @@ -0,0 +1,40 @@ +"""Versioned JSON protocol shared with the Go TUI.""" + +from __future__ import annotations + +from typing import Any + + +PROTOCOL_VERSION = 3 +PROTOCOL_CAPABILITIES = ( + "state-revisions", + "collection-deltas", + "structured-command-errors", + "agents-collection", +) + +# Commands and control messages are intentionally small. Event and finding +# history uses a separate bounded collection stream so a resumed run can be +# larger than any individual frame. +MAX_COMMAND_BYTES = 64 * 1024 +MAX_COLLECTION_FRAME_BYTES = 4 * 1024 * 1024 + + +class ProtocolHandshakeError(RuntimeError): + """Raised before the Go TUI is activated when v3 negotiation fails.""" + + +def envelope( + message_type: str, + payload: dict[str, Any], + *, + request_id: str | None = None, +) -> dict[str, Any]: + message: dict[str, Any] = { + "version": PROTOCOL_VERSION, + "type": message_type, + "payload": payload, + } + if request_id: + message["request_id"] = request_id + return message diff --git a/strix/interface/tui/backend/server.py b/strix/interface/tui/backend/server.py new file mode 100644 index 00000000..f884b3b6 --- /dev/null +++ b/strix/interface/tui/backend/server.py @@ -0,0 +1,531 @@ +"""Private framed IPC connection used by the Go TUI.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import struct +from collections import deque +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from strix.interface.tui.backend.projection import sanitize_terminal_text +from strix.interface.tui.backend.protocol import ( + MAX_COLLECTION_FRAME_BYTES, + MAX_COMMAND_BYTES, + PROTOCOL_CAPABILITIES, + PROTOCOL_VERSION, + ProtocolHandshakeError, + envelope, +) + + +if TYPE_CHECKING: + import socket + + from strix.interface.tui.backend.controller import TuiController + +logger = logging.getLogger(__name__) + +_HEADER = struct.Struct(">I") +_HANDSHAKE_TIMEOUT = 10.0 +_COLLECTIONS = ("agents", "events", "vulnerabilities") +_COLLECTION_ITEM_LIMITS = {"events": 5_000, "vulnerabilities": 1_000} +# Leave enough room for the collection envelope and cursor metadata. +_COLLECTION_PAYLOAD_TARGET = MAX_COLLECTION_FRAME_BYTES - 16 * 1024 + + +class _MessageTooLargeError(ValueError): + pass + + +@dataclass +class _CollectionState: + revision: int = 0 + bootstrapped: bool = False + order: list[str] = field(default_factory=list) + items: dict[str, dict[str, Any]] = field(default_factory=dict) + fingerprints: dict[str, str] = field(default_factory=dict) + source_cursor: int | None = None + + +class TuiBackendServer: + """Serve one TUI child over an authenticated, connected socket.""" + + def __init__(self, controller: TuiController) -> None: + self.controller = controller + self._socket: socket.socket | None = None + self._reader_task: asyncio.Task[None] | None = None + self._broadcast_event = asyncio.Event() + self._broadcast_task: asyncio.Task[None] | None = None + self._write_lock = asyncio.Lock() + self._sync_lock = asyncio.Lock() + self._state_revision = 0 + self._state_fingerprint = "" + self._collections = {name: _CollectionState() for name in _COLLECTIONS} + self._seen_request_ids: set[str] = set() + self._request_id_order: deque[str] = deque() + self.activated = False + controller.set_change_callback(self.notify_changed) + + async def start(self, connection: socket.socket) -> None: + """Negotiate protocol v3 before activating command or state traffic.""" + if self._socket is not None: + raise RuntimeError("TUI backend is already started") + connection.setblocking(False) # noqa: FBT003 + self._socket = connection + try: + await self._send(envelope("hello", {"capabilities": list(PROTOCOL_CAPABILITIES)})) + await asyncio.wait_for(self._receive_ready(), timeout=_HANDSHAKE_TIMEOUT) + except TimeoutError as exc: + raise ProtocolHandshakeError("Timed out waiting for TUI protocol ready") from exc + except (EOFError, ConnectionError, OSError) as exc: + raise ProtocolHandshakeError(f"TUI closed during protocol handshake: {exc}") from exc + except (TypeError, ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ProtocolHandshakeError(str(exc)) from exc + + self.activated = True + self._reader_task = asyncio.create_task(self._read_loop()) + self._broadcast_task = asyncio.create_task(self._broadcast_loop()) + self.notify_changed() + + async def close(self) -> None: + tasks = [task for task in (self._reader_task, self._broadcast_task) if task is not None] + for task in tasks: + task.cancel() + for task in tasks: + if task is asyncio.current_task(): + continue + with contextlib.suppress(asyncio.CancelledError): + await task + self._reader_task = None + self._broadcast_task = None + self._close_socket() + + def _close_socket(self) -> None: + if self._socket is not None: + self._socket.close() + self._socket = None + + def notify_changed(self) -> None: + if self.activated: + self._broadcast_event.set() + + async def _read_exactly(self, size: int) -> bytes: + connection = self._socket + if connection is None: + raise ConnectionError("TUI IPC connection is closed") + loop = asyncio.get_running_loop() + chunks: list[bytes] = [] + remaining = size + while remaining: + chunk = await loop.sock_recv(connection, remaining) + if not chunk: + raise EOFError("TUI IPC peer closed") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + async def _read_frame(self, maximum: int) -> bytes: + (size,) = _HEADER.unpack(await self._read_exactly(_HEADER.size)) + if size == 0 or size > maximum: + # Reject the length before allocating or reading its payload. + raise ConnectionError(f"invalid TUI IPC frame size: {size}") + return await self._read_exactly(size) + + async def _receive_ready(self) -> None: + raw = await self._read_frame(MAX_COMMAND_BYTES) + message = json.loads(raw.decode("utf-8")) + if not isinstance(message, dict): + raise TypeError("TUI ready message must be an object") + if message.get("version") != PROTOCOL_VERSION: + raise ValueError( + f"TUI protocol mismatch: expected v{PROTOCOL_VERSION}, " + f"received v{message.get('version')}" + ) + if message.get("type") != "ready": + raise ValueError("TUI protocol handshake expected ready") + payload = message.get("payload") + if not isinstance(payload, dict): + raise TypeError("TUI ready payload must be an object") + capabilities = payload.get("capabilities") + if capabilities != list(PROTOCOL_CAPABILITIES): + raise ValueError("TUI protocol capability mismatch") + + async def _read_loop(self) -> None: + try: + while True: + raw = await self._read_frame(MAX_COMMAND_BYTES) + response, resync = await self._handle_message(raw) + if response is not None: + await self._send_command_response(response) + if resync is not None: + await self._resync_collection(resync) + except asyncio.CancelledError: + raise + except (EOFError, ConnectionError, OSError): + self._close_socket() + + @staticmethod + def _decode_message(raw: bytes) -> tuple[str, str, dict[str, object]]: + message = json.loads(raw.decode("utf-8")) + if not isinstance(message, dict): + raise TypeError("message must be an object") + request_id = message.get("request_id") + if not isinstance(request_id, str) or not request_id: + raise ValueError("command request_id must be a non-empty string") + if message.get("version") != PROTOCOL_VERSION: + raise ValueError(f"unsupported protocol version; expected {PROTOCOL_VERSION}") + command = message.get("type") + payload = message.get("payload", {}) + if not isinstance(command, str) or not isinstance(payload, dict): + raise TypeError("invalid command envelope") + if len(command) > 128: + raise ValueError("command name exceeds 128 characters") + return request_id, command, payload + + @staticmethod + def _structured_error(exc: Exception) -> dict[str, object]: + if isinstance(exc, OSError): + return {"code": "persistence_error", "message": str(exc), "retryable": True} + if isinstance(exc, TypeError | ValueError | json.JSONDecodeError | UnicodeDecodeError): + return {"code": "invalid_request", "message": str(exc), "retryable": False} + if isinstance(exc, RuntimeError): + return {"code": "command_failed", "message": str(exc), "retryable": False} + logger.exception("Unhandled TUI command error", exc_info=exc) + return { + "code": "internal_error", + "message": "The command failed unexpectedly", + "retryable": True, + } + + async def _handle_message(self, raw: bytes) -> tuple[dict[str, Any] | None, str | None]: + request_id: str | None = None + command = "" + resync: str | None = None + try: + preliminary = json.loads(raw.decode("utf-8")) + if isinstance(preliminary, dict): + raw_request_id = preliminary.get("request_id") + if isinstance(raw_request_id, str) and raw_request_id: + request_id = raw_request_id + raw_command = preliminary.get("type") + if isinstance(raw_command, str): + command = raw_command[:128] + request_id, command, payload = self._decode_message(raw) + if request_id in self._seen_request_ids: + raise ValueError(f"duplicate request_id: {request_id}") # noqa: TRY301 + self._seen_request_ids.add(request_id) + self._request_id_order.append(request_id) + if len(self._request_id_order) > 10_000: + self._seen_request_ids.discard(self._request_id_order.popleft()) + if command == "collection.resync": + collection = payload.get("collection") + if not isinstance(collection, str) or collection not in _COLLECTIONS: + choices = ", ".join(_COLLECTIONS) + raise ValueError(f"collection must be one of: {choices}") # noqa: TRY301 + result: dict[str, Any] = {"collection": collection, "resyncing": True} + resync = collection + else: + result = await self.controller.handle(command, payload) + response = envelope( + "command_result", + {"ok": True, "command": command, "result": result}, + request_id=request_id, + ) + except Exception as exc: # noqa: BLE001 - command failures are protocol results + if request_id is None: + # A malformed envelope without an ID cannot be correlated. Keep + # the reader alive and wait for the next valid command. + logger.warning("Ignoring uncorrelatable TUI command: %s", exc) + return None, None + response = envelope( + "command_result", + { + "ok": False, + "command": command, + "error": self._structured_error(exc), + }, + request_id=request_id, + ) + return response, resync + + def _encode(self, message: dict[str, Any]) -> bytes: + raw = json.dumps( + self._sanitize_wire_value(message), + default=str, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + maximum = ( + MAX_COLLECTION_FRAME_BYTES + if message.get("type") in {"collection_bootstrap", "collection_delta"} + else MAX_COMMAND_BYTES + ) + if len(raw) > maximum: + raise _MessageTooLargeError(f"TUI IPC message exceeds {maximum} bytes") + return raw + + @classmethod + def _sanitize_wire_value(cls, value: Any) -> Any: + if isinstance(value, str): + return sanitize_terminal_text(value) + if isinstance(value, dict): + return { + sanitize_terminal_text(str(key)): cls._sanitize_wire_value(item) + for key, item in value.items() + } + if isinstance(value, list): + return [cls._sanitize_wire_value(item) for item in value] + if isinstance(value, tuple): + return [cls._sanitize_wire_value(item) for item in value] + return value + + async def _send(self, message: dict[str, Any]) -> None: + connection = self._socket + if connection is None: + raise ConnectionError("TUI IPC connection is closed") + raw = self._encode(message) + framed = _HEADER.pack(len(raw)) + raw + async with self._write_lock: + await asyncio.get_running_loop().sock_sendall(connection, framed) + + async def _send_command_response(self, response: dict[str, Any]) -> None: + try: + await self._send(response) + except _MessageTooLargeError: + request_id = response.get("request_id") + payload = response.get("payload") + command = payload.get("command", "") if isinstance(payload, dict) else "" + await self._send( + envelope( + "command_result", + { + "ok": False, + "command": command, + "error": { + "code": "result_too_large", + "message": "Command result exceeds the terminal frame limit", + "retryable": False, + }, + }, + request_id=request_id if isinstance(request_id, str) else None, + ) + ) + + @staticmethod + def _fingerprint(value: Any) -> str: + return json.dumps(value, default=str, sort_keys=True, separators=(",", ":")) + + async def _send_state_if_changed(self) -> None: + state = self.controller.snapshot() + fingerprint = self._fingerprint(state) + if fingerprint == self._state_fingerprint: + return + revision = self._state_revision + 1 + await self._send(envelope("state", {"revision": revision, "state": state})) + self._state_revision = revision + self._state_fingerprint = fingerprint + + @staticmethod + def _collection_values( + items: list[dict[str, Any]], + ) -> tuple[list[str], dict[str, dict[str, Any]], dict[str, str]]: + order: list[str] = [] + by_id: dict[str, dict[str, Any]] = {} + fingerprints: dict[str, str] = {} + for item in items: + item_id = item.get("id") + if not isinstance(item_id, str) or not item_id: + continue + order.append(item_id) + by_id[item_id] = item + fingerprints[item_id] = TuiBackendServer._fingerprint(item) + return order, by_id, fingerprints + + async def _send_collection_frames( + self, + message_type: str, + fixed: dict[str, Any], + field_name: str, + values: list[dict[str, Any]], + ) -> None: + cursor = 0 + if not values: + payload = {**fixed, "cursor": 0, "next_cursor": 0, "done": True, field_name: []} + await self._send(envelope(message_type, payload)) + return + + while cursor < len(values): + chunk: list[dict[str, Any]] = [] + next_cursor = cursor + empty_payload = { + **fixed, + "cursor": cursor, + "next_cursor": cursor, + "done": False, + field_name: [], + } + estimated_size = len( + json.dumps( + envelope(message_type, empty_payload), + default=str, + separators=(",", ":"), + ).encode("utf-8") + ) + while next_cursor < len(values): + item = values[next_cursor] + item_size = len( + json.dumps(item, default=str, separators=(",", ":")).encode("utf-8") + ) + if estimated_size + item_size + 1 > _COLLECTION_PAYLOAD_TARGET and chunk: + break + chunk.append(item) + estimated_size += item_size + 1 + next_cursor += 1 + payload = { + **fixed, + "cursor": cursor, + "next_cursor": next_cursor, + "done": next_cursor == len(values), + field_name: chunk, + } + await self._send(envelope(message_type, payload)) + cursor = next_cursor + + async def _send_collection_bootstrap( + self, + name: str, + items: list[dict[str, Any]] | None = None, + ) -> None: + state = self._collections[name] + source_cursor: int | None = None + if items is None: + source_cursor, projected = self.controller.collection_snapshot(name) + else: + projected = items + order, by_id, fingerprints = self._collection_values(projected) + revision = state.revision + 1 + await self._send_collection_frames( + "collection_bootstrap", + {"collection": name, "revision": revision}, + "items", + [by_id[item_id] for item_id in order], + ) + state.revision = revision + state.bootstrapped = True + state.order = order + state.items = by_id + state.fingerprints = fingerprints + state.source_cursor = source_cursor + + async def _send_collection_if_changed(self, name: str) -> None: + state = self._collections[name] + if name == "events" and state.bootstrapped and state.source_cursor is not None: + next_cursor, changed = self.controller.collection_changes( + name, + state.source_cursor, + ) + if next_cursor == state.source_cursor: + return + operations: list[dict[str, Any]] = [] + for item in changed: + item_id = item.get("id") + if not isinstance(item_id, str) or not item_id: + continue + operations.append({"op": "upsert", "item": item}) + if item_id not in state.items: + state.order.append(item_id) + state.items[item_id] = item + state.fingerprints[item_id] = self._fingerprint(item) + limit = _COLLECTION_ITEM_LIMITS[name] + while len(state.order) > limit: + removed_id = state.order.pop(0) + state.items.pop(removed_id, None) + state.fingerprints.pop(removed_id, None) + operations.append({"op": "delete", "id": removed_id}) + if operations: + revision = state.revision + 1 + await self._send_collection_frames( + "collection_delta", + { + "collection": name, + "base_revision": state.revision, + "revision": revision, + }, + "operations", + operations, + ) + state.revision = revision + state.source_cursor = next_cursor + return + projected = self.controller.collection(name) + order, by_id, fingerprints = self._collection_values(projected) + if not state.bootstrapped: + await self._send_collection_bootstrap( + name, + None if name == "events" else projected, + ) + return + if order == state.order and fingerprints == state.fingerprints: + return + + retained = [item_id for item_id in state.order if item_id in by_id] + expected_order = retained + [item_id for item_id in order if item_id not in state.items] + if order != expected_order: + await self._send_collection_bootstrap(name, projected) + return + + operations = [ + {"op": "delete", "id": item_id} for item_id in state.order if item_id not in by_id + ] + [ + {"op": "upsert", "item": by_id[item_id]} + for item_id in order + if fingerprints[item_id] != state.fingerprints.get(item_id) + ] + if not operations: + await self._send_collection_bootstrap(name, projected) + return + + revision = state.revision + 1 + await self._send_collection_frames( + "collection_delta", + { + "collection": name, + "base_revision": state.revision, + "revision": revision, + }, + "operations", + operations, + ) + state.revision = revision + state.order = order + state.items = by_id + state.fingerprints = fingerprints + + async def _flush_updates(self) -> None: + async with self._sync_lock: + await self._send_state_if_changed() + for name in _COLLECTIONS: + await self._send_collection_if_changed(name) + + async def _resync_collection(self, name: str) -> None: + async with self._sync_lock: + await self._send_collection_bootstrap(name) + + async def _broadcast_loop(self) -> None: + try: + while True: + await self._broadcast_event.wait() + self._broadcast_event.clear() + await asyncio.sleep(0.05) + await self._flush_updates() + except asyncio.CancelledError: + raise + except (_MessageTooLargeError, ValueError): + logger.exception("TUI projection could not be framed") + self._close_socket() + except (ConnectionError, OSError): + self._close_socket() diff --git a/strix/interface/tui/cmd/strix-tui/main.go b/strix/interface/tui/cmd/strix-tui/main.go new file mode 100644 index 00000000..335725d5 --- /dev/null +++ b/strix/interface/tui/cmd/strix-tui/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/usestrix/strix/tui/internal/app" + "github.com/usestrix/strix/tui/internal/render" +) + +func main() { + app.SetVersion(os.Getenv("STRIX_VERSION")) + render.DetectKittyGraphics() + client, err := app.ConnectFromEnvironment() + if err != nil { + fmt.Fprintln(os.Stderr, "connect to Strix backend:", err) + os.Exit(1) + } + defer client.Close() + if err := client.Handshake(); err != nil { + fmt.Fprintln(os.Stderr, "negotiate Strix TUI protocol:", err) + os.Exit(1) + } + program := tea.NewProgram(app.New(client), tea.WithAltScreen(), tea.WithMouseCellMotion()) + finalModel, err := program.Run() + if err != nil { + fmt.Fprintln(os.Stderr, "run TUI:", err) + os.Exit(1) + } + if model, ok := finalModel.(interface{ FatalError() error }); ok && model.FatalError() != nil { + fmt.Fprintln(os.Stderr, "run TUI:", model.FatalError()) + os.Exit(1) + } +} diff --git a/strix/interface/tui/go.mod b/strix/interface/tui/go.mod new file mode 100644 index 00000000..1ff6ec35 --- /dev/null +++ b/strix/interface/tui/go.mod @@ -0,0 +1,32 @@ +module github.com/usestrix/strix/tui + +go 1.24.0 + +require ( + github.com/alecthomas/chroma/v2 v2.14.0 + github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.10.1 + github.com/charmbracelet/x/term v0.2.1 + github.com/muesli/termenv v0.16.0 + golang.org/x/sys v0.36.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/strix/interface/tui/go.sum b/strix/interface/tui/go.sum new file mode 100644 index 00000000..f255aec1 --- /dev/null +++ b/strix/interface/tui/go.sum @@ -0,0 +1,61 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/strix/interface/tui/internal/app/agents.go b/strix/interface/tui/internal/app/agents.go new file mode 100644 index 00000000..bd590254 --- /dev/null +++ b/strix/interface/tui/internal/app/agents.go @@ -0,0 +1,253 @@ +package app + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/usestrix/strix/tui/internal/protocol" + "github.com/usestrix/strix/tui/internal/render" +) + +type agentTreeEntry struct { + index int + depth int + prefix string +} + +// agentTreeEntries mirrors Textual Tree's depth-first ordering while retaining +// each agent's snapshot index for event lookup and commands. +func agentTreeEntries(agents []protocol.Agent, collapsed map[string]bool) []agentTreeEntry { + indexByID := make(map[string]int, len(agents)) + for i, agent := range agents { + indexByID[agent.ID] = i + } + children := make(map[int][]int, len(agents)) + var roots []int + for i, agent := range agents { + parentIndex := -1 + if agent.ParentID != nil { + if candidate, ok := indexByID[*agent.ParentID]; ok && candidate != i { + parentIndex = candidate + } + } + if parentIndex < 0 { + roots = append(roots, i) + } else { + children[parentIndex] = append(children[parentIndex], i) + } + } + + entries := make([]agentTreeEntry, 0, len(agents)) + visited := make(map[int]bool, len(agents)) + var hideDescendants func(int) + hideDescendants = func(index int) { + for _, child := range children[index] { + if visited[child] { + continue + } + visited[child] = true + hideDescendants(child) + } + } + var walk func(int, int, []bool, bool) + walk = func(index, depth int, continuations []bool, isLast bool) { + if visited[index] { + return + } + visited[index] = true + var prefix strings.Builder + if depth > 0 { + for _, continues := range continuations { + if continues { + prefix.WriteString("β”‚ ") + } else { + prefix.WriteString(" ") + } + } + if isLast { + prefix.WriteString("└─ ") + } else { + prefix.WriteString("β”œβ”€ ") + } + } + entries = append(entries, agentTreeEntry{index: index, depth: depth, prefix: prefix.String()}) + if collapsed[agents[index].ID] { + hideDescendants(index) + return + } + nextContinuations := continuations + if depth > 0 { + nextContinuations = append(append([]bool(nil), continuations...), !isLast) + } + for i, child := range children[index] { + walk(child, depth+1, nextContinuations, i == len(children[index])-1) + } + } + for i, root := range roots { + walk(root, 0, nil, i == len(roots)-1) + } + // Malformed cycles have no root. Keep their nodes visible rather than losing + // them, treating the first unvisited node as another root. + for i := range agents { + if !visited[i] { + walk(i, 0, nil, true) + } + } + return entries +} + +func hasAgentChildren(agentID string, agents []protocol.Agent) bool { + for _, agent := range agents { + if agent.ParentID != nil && *agent.ParentID == agentID { + return true + } + } + return false +} + +func windowStart(offset, length, size int) int { + return min(max(0, offset), max(0, length-size)) +} + +func selectedAgentRow(entries []agentTreeEntry, selectedIndex int) int { + for row, entry := range entries { + if entry.index == selectedIndex { + return row + } + } + return 0 +} + +func selectedAgentIndex(agents []protocol.Agent, selectedID string) int { + if selectedID != "" { + for i, agent := range agents { + if agent.ID == selectedID { + return i + } + } + } + return 0 +} + +func (m Model) selectedAgentID() string { + if m.selectedAgent >= 0 && m.selectedAgent < len(m.snapshot.Agents) { + return m.snapshot.Agents[m.selectedAgent].ID + } + return "" +} + +func (m Model) selectedAgentCanStop() bool { + if m.selectedAgent < 0 || m.selectedAgent >= len(m.snapshot.Agents) { + return false + } + switch m.snapshot.Agents[m.selectedAgent].Status { + case "running", "waiting", "budget_paused": + return true + default: + return false + } +} + +func (m Model) agentsView(width, height int) string { + // The tree's root ("Agents") is hidden (show_root = False), so no header row + // is drawn β€” only the agent nodes. + var lines []string + statusIcons := map[string]string{"running": "βšͺ", "waiting": "⏸", "budget_paused": "⏸", "completed": "🟒", "failed": "πŸ”΄", "crashed": "πŸ”΄", "stopped": "β– "} + entries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents) + start := windowStart(m.agentOffset, len(entries), height) + end := min(len(entries), start+height) + for _, entry := range entries[start:end] { + agent := m.snapshot.Agents[entry.index] + icon := statusIcons[agent.Status] + if icon == "" { + icon = "β—‹" + } + vulnSuffix := "" + if count := m.agentVulnCount(agent.ID); count > 0 { + vulnSuffix = fmt.Sprintf(" (%d)", count) + } + // Only a node with children carries a toggle; a leaf renders none at all, + // so its icon sits where its parent's toggle would be. + disclosure := "" + if hasAgentChildren(agent.ID, m.snapshot.Agents) { + disclosure = "β–Ό " + if m.collapsedAgents[agent.ID] { + disclosure = "β–Ά " + } + } + label := disclosure + icon + " " + agent.Name + vulnSuffix + // The guides are dim and stay outside the cursor; the cursor is a filled + // block behind the label alone. + labelStyle := lipgloss.NewStyle().Foreground(treeLabel) + if entry.index == m.selectedAgent { + labelStyle = labelStyle.Foreground(treeCursorFg).Background(treeCursorBg).Bold(true) + } + room := max(1, width-lipgloss.Width(entry.prefix)) + lines = append(lines, + lipgloss.NewStyle().Foreground(treeGuide).Render(entry.prefix)+ + labelStyle.Render(truncate(label, room))) + } + return strings.Join(lines, "\n") +} + +// agentVulnCount counts vulnerabilities attributed to an agent, matching the +// " (N)" suffix _update_agent_node appends to each tree node. +func (m Model) agentVulnCount(agentID string) int { + count := 0 + for _, vuln := range m.snapshot.Vulnerabilities { + if render.StringValue(vuln["agent_id"]) == agentID { + count++ + } + } + return count +} + +func (m *Model) ensureAgentVisible() { + entries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents) + if len(entries) == 0 { + m.agentOffset = 0 + return + } + _, _, agentHeight := m.sidebarHeights() + rows := max(1, agentHeight-4) + row := selectedAgentRow(entries, m.selectedAgent) + if row < m.agentOffset { + m.agentOffset = row + } else if row >= m.agentOffset+rows { + m.agentOffset = row - rows + 1 + } + m.agentOffset = min(m.agentOffset, max(0, len(entries)-rows)) +} + +func (m Model) agentPageSize() int { + _, _, agentHeight := m.sidebarHeights() + return max(1, agentHeight-4) +} + +func (m *Model) keepAgentSelectionInWindow() { + entries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents) + if len(entries) == 0 { + return + } + rows := m.agentPageSize() + row := selectedAgentRow(entries, m.selectedAgent) + if row < m.agentOffset { + m.selectedAgent = entries[m.agentOffset].index + } else if row >= m.agentOffset+rows { + m.selectedAgent = entries[min(len(entries)-1, m.agentOffset+rows-1)].index + } +} + +func (m Model) agentHasEvents(agentID string) bool { + for _, event := range m.snapshot.Events { + if event.AgentID == agentID { + return true + } + } + return false +} + +// sweepView ports _get_sweep_animation: a triangle-wave sweep of six squares +// across an 8-color palette (dimmest shows a "Β·"), matching the Python cadence +// and motion exactly. diff --git a/strix/interface/tui/internal/app/client.go b/strix/interface/tui/internal/app/client.go new file mode 100644 index 00000000..f1344de3 --- /dev/null +++ b/strix/interface/tui/internal/app/client.go @@ -0,0 +1,250 @@ +package app + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "reflect" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/usestrix/strix/tui/internal/protocol" +) + +const ( + maxCommandBytes = 64 << 10 + maxCollectionBytes = 4 << 20 +) + +var ErrCommandPending = errors.New("command is already pending") + +type Client struct { + conn io.ReadWriteCloser + mu sync.Mutex + seq atomic.Uint64 + pending map[string]string + pendingByKey map[string]string + requestKeyByID map[string]string +} + +// ConnectInherited opens the connected socket descriptor passed by the Python +// parent. No listener, network address, or authentication secret is involved. +func ConnectInherited(fdValue string) (*Client, error) { + fd, err := strconv.ParseUint(fdValue, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid STRIX_TUI_FD: %w", err) + } + file := os.NewFile(uintptr(fd), "strix-tui-ipc") + if file == nil { + return nil, fmt.Errorf("invalid STRIX_TUI_FD %d", fd) + } + connection, err := net.FileConn(file) + _ = file.Close() + if err != nil { + return nil, fmt.Errorf("open inherited TUI connection: %w", err) + } + return newClient(connection), nil +} + +func newClient(connection io.ReadWriteCloser) *Client { + return &Client{ + conn: connection, + pending: map[string]string{}, + pendingByKey: map[string]string{}, + requestKeyByID: map[string]string{}, + } +} + +// ConnectFromEnvironment selects the private transport prepared by the Python +// parent. POSIX uses an inherited descriptor; Windows uses an authenticated +// one-use loopback connection because pass_fds is unavailable there. +func ConnectFromEnvironment() (*Client, error) { + if fd := os.Getenv("STRIX_TUI_FD"); fd != "" { + _ = os.Unsetenv("STRIX_TUI_FD") + return ConnectInherited(fd) + } + + address := os.Getenv("STRIX_TUI_ADDR") + token := os.Getenv("STRIX_TUI_TOKEN") + _ = os.Unsetenv("STRIX_TUI_ADDR") + _ = os.Unsetenv("STRIX_TUI_TOKEN") + if address == "" || token == "" { + return nil, fmt.Errorf("STRIX_TUI_FD or STRIX_TUI_ADDR and STRIX_TUI_TOKEN are required") + } + + connection, err := net.DialTimeout("tcp", address, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("connect to TUI backend: %w", err) + } + if err := writeAll(connection, []byte(token)); err != nil { + connection.Close() + return nil, fmt.Errorf("authenticate to TUI backend: %w", err) + } + return newClient(connection), nil +} + +func writeAll(writer io.Writer, data []byte) error { + for len(data) > 0 { + n, err := writer.Write(data) + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + data = data[n:] + } + return nil +} + +func (c *Client) readEnvelope(maximum uint32) (protocol.Envelope, int, error) { + var header [4]byte + if _, err := io.ReadFull(c.conn, header[:]); err != nil { + return protocol.Envelope{}, 0, err + } + size := binary.BigEndian.Uint32(header[:]) + if size == 0 || size > maximum { + return protocol.Envelope{}, 0, fmt.Errorf("invalid TUI IPC message size: %d", size) + } + raw := make([]byte, size) + if _, err := io.ReadFull(c.conn, raw); err != nil { + return protocol.Envelope{}, 0, err + } + var envelope protocol.Envelope + if err := json.Unmarshal(raw, &envelope); err != nil { + return protocol.Envelope{}, 0, err + } + return envelope, int(size), nil +} + +func (c *Client) Read() (protocol.Envelope, error) { + envelope, size, err := c.readEnvelope(maxCollectionBytes) + if err != nil { + return protocol.Envelope{}, err + } + if envelope.Type != "collection_bootstrap" && envelope.Type != "collection_delta" && size > maxCommandBytes { + return protocol.Envelope{}, fmt.Errorf("TUI control message exceeds %d bytes", maxCommandBytes) + } + return envelope, nil +} + +// Handshake validates the exact v3 hello and acknowledges readiness. main calls +// this before constructing Bubble Tea, so mismatch errors never enter alt screen. +func (c *Client) Handshake() error { + if connection, ok := c.conn.(interface{ SetDeadline(time.Time) error }); ok { + if err := connection.SetDeadline(time.Now().Add(10 * time.Second)); err != nil { + return err + } + defer connection.SetDeadline(time.Time{}) //nolint:errcheck + } + envelope, _, err := c.readEnvelope(maxCommandBytes) + if err != nil { + return fmt.Errorf("read protocol hello: %w", err) + } + if envelope.Version != protocol.Version { + return fmt.Errorf("protocol mismatch: backend=%d client=%d", envelope.Version, protocol.Version) + } + if envelope.Type != "hello" { + return fmt.Errorf("protocol handshake expected hello, received %q", envelope.Type) + } + var hello protocol.Hello + if err := json.Unmarshal(envelope.Payload, &hello); err != nil { + return fmt.Errorf("decode protocol hello: %w", err) + } + if !reflect.DeepEqual(hello.Capabilities, protocol.Capabilities) { + return fmt.Errorf("protocol capability mismatch") + } + payload, err := json.Marshal(protocol.Hello{Capabilities: protocol.Capabilities}) + if err != nil { + return err + } + return c.sendEnvelope(protocol.Envelope{ + Version: protocol.Version, + Type: "ready", + Payload: payload, + }, maxCommandBytes) +} + +func (c *Client) sendEnvelope(envelope protocol.Envelope, maximum int) error { + raw, err := json.Marshal(envelope) + if err != nil { + return err + } + if len(raw) > maximum { + return fmt.Errorf("TUI IPC message exceeds %d bytes", maximum) + } + framed := make([]byte, 4+len(raw)) + binary.BigEndian.PutUint32(framed[:4], uint32(len(raw))) + copy(framed[4:], raw) + return writeAll(c.conn, framed) +} + +func pendingKey(command string, payload json.RawMessage) string { + if command == "collection.resync" { + return command + ":" + string(payload) + } + return command +} + +func (c *Client) Send(command string, payload any) (string, error) { + rawPayload, err := json.Marshal(payload) + if err != nil { + return "", err + } + requestID := fmt.Sprintf("go-%d", c.seq.Add(1)) + envelope := protocol.Envelope{ + Version: protocol.Version, Type: command, RequestID: requestID, Payload: rawPayload, + } + key := pendingKey(command, rawPayload) + + c.mu.Lock() + defer c.mu.Unlock() + if c.pending == nil { + c.pending = map[string]string{} + c.pendingByKey = map[string]string{} + c.requestKeyByID = map[string]string{} + } + if existing := c.pendingByKey[key]; existing != "" { + return "", fmt.Errorf("%w: %s (%s)", ErrCommandPending, command, existing) + } + c.pending[requestID] = command + c.pendingByKey[key] = requestID + c.requestKeyByID[requestID] = key + if err := c.sendEnvelope(envelope, maxCommandBytes); err != nil { + delete(c.pending, requestID) + delete(c.pendingByKey, key) + delete(c.requestKeyByID, requestID) + return "", err + } + return requestID, nil +} + +// Resolve accepts only the exact request/command pair that was submitted. +// Unknown or mismatched results remain inert and do not release pending state. +func (c *Client) Resolve(requestID, command string) bool { + c.mu.Lock() + defer c.mu.Unlock() + if requestID == "" || c.pending[requestID] != command { + return false + } + key := c.requestKeyByID[requestID] + delete(c.pending, requestID) + delete(c.pendingByKey, key) + delete(c.requestKeyByID, requestID) + return true +} + +func (c *Client) ExpectedCommand(requestID string) (string, bool) { + c.mu.Lock() + defer c.mu.Unlock() + command, ok := c.pending[requestID] + return command, ok +} + +func (c *Client) Close() error { return c.conn.Close() } diff --git a/strix/interface/tui/internal/app/client_test.go b/strix/interface/tui/internal/app/client_test.go new file mode 100644 index 00000000..651355b0 --- /dev/null +++ b/strix/interface/tui/internal/app/client_test.go @@ -0,0 +1,284 @@ +package app + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "reflect" + "strings" + "testing" + + "github.com/usestrix/strix/tui/internal/protocol" +) + +func writeEnvelopeFrame(writer io.Writer, envelope protocol.Envelope) error { + raw, err := json.Marshal(envelope) + if err != nil { + return err + } + var header [4]byte + binary.BigEndian.PutUint32(header[:], uint32(len(raw))) + return writeAll(writer, append(header[:], raw...)) +} + +func readEnvelopeFrame(reader io.Reader) (protocol.Envelope, error) { + var header [4]byte + if _, err := io.ReadFull(reader, header[:]); err != nil { + return protocol.Envelope{}, err + } + raw := make([]byte, binary.BigEndian.Uint32(header[:])) + if _, err := io.ReadFull(reader, raw); err != nil { + return protocol.Envelope{}, err + } + var envelope protocol.Envelope + return envelope, json.Unmarshal(raw, &envelope) +} + +func TestHandshakeValidatesHelloAndSendsReady(t *testing.T) { + server, connection := net.Pipe() + client := newClient(connection) + serverErr := make(chan error, 1) + go func() { + defer server.Close() + payload, _ := json.Marshal(protocol.Hello{Capabilities: protocol.Capabilities}) + if err := writeEnvelopeFrame(server, protocol.Envelope{Version: protocol.Version, Type: "hello", Payload: payload}); err != nil { + serverErr <- err + return + } + var header [4]byte + if _, err := io.ReadFull(server, header[:]); err != nil { + serverErr <- err + return + } + raw := make([]byte, binary.BigEndian.Uint32(header[:])) + if _, err := io.ReadFull(server, raw); err != nil { + serverErr <- err + return + } + var ready protocol.Envelope + if err := json.Unmarshal(raw, &ready); err != nil { + serverErr <- err + return + } + var readyPayload protocol.Hello + if err := json.Unmarshal(ready.Payload, &readyPayload); err != nil { + serverErr <- err + return + } + if ready.Type != "ready" || ready.Version != protocol.Version || !reflect.DeepEqual(readyPayload.Capabilities, protocol.Capabilities) { + serverErr <- fmt.Errorf("unexpected ready: %#v %#v", ready, readyPayload) + return + } + serverErr <- nil + }() + + if err := client.Handshake(); err != nil { + t.Fatal(err) + } + if err := <-serverErr; err != nil { + t.Fatal(err) + } +} + +func TestHandshakeRejectsMismatchBeforeReady(t *testing.T) { + server, connection := net.Pipe() + client := newClient(connection) + go func() { + defer server.Close() + payload, _ := json.Marshal(protocol.Hello{Capabilities: []string{"state-revisions"}}) + _ = writeEnvelopeFrame(server, protocol.Envelope{Version: 2, Type: "hello", Payload: payload}) + }() + + err := client.Handshake() + if err == nil || !strings.Contains(err.Error(), "protocol mismatch") { + t.Fatalf("handshake error = %v, want protocol mismatch", err) + } +} + +func TestReadRejectsOversizedCollectionLengthBeforePayload(t *testing.T) { + server, connection := net.Pipe() + client := newClient(connection) + written := make(chan error, 1) + go func() { + var header [4]byte + binary.BigEndian.PutUint32(header[:], maxCollectionBytes+1) + _, err := server.Write(header[:]) + written <- err + }() + + _, err := client.Read() + if err == nil || !strings.Contains(err.Error(), "invalid TUI IPC message size") { + t.Fatalf("read error = %v", err) + } + if err := <-written; err != nil { + t.Fatal(err) + } + server.Close() +} + +func TestClientPreventsDuplicateCommandsAndRequiresExactCorrelation(t *testing.T) { + connection := &recordingConn{} + client := newClient(connection) + requestID, err := client.Send("setup.select_model", map[string]string{"model": "openai/gpt-5"}) + if err != nil { + t.Fatal(err) + } + if _, err := client.Send("setup.select_model", map[string]string{"model": "openai/gpt-5.1"}); !errors.Is(err, ErrCommandPending) { + t.Fatalf("duplicate error = %v, want ErrCommandPending", err) + } + if client.Resolve("unknown", "setup.select_model") || client.Resolve(requestID, "models.list") { + t.Fatal("unknown or mismatched result resolved pending request") + } + if !client.Resolve(requestID, "setup.select_model") { + t.Fatal("exact result did not resolve pending request") + } + if _, err := client.Send("setup.select_model", map[string]string{"model": "openai/gpt-5.1"}); err != nil { + t.Fatalf("command remained blocked after success: %v", err) + } +} + +func TestClientRejectsOversizedCommandBeforeWrite(t *testing.T) { + connection := &recordingConn{} + client := newClient(connection) + _, err := client.Send("setup.set_instruction", map[string]string{"instruction": strings.Repeat("x", maxCommandBytes)}) + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("oversized send error = %v", err) + } + if connection.Len() != 0 || len(client.pending) != 0 { + t.Fatal("oversized command was written or left pending") + } +} + +func TestClientReadsCollectionFrameLargerThanOneMegabyte(t *testing.T) { + server, connection := net.Pipe() + client := &Client{conn: connection} + payload, err := json.Marshal(map[string]string{"content": string(bytes.Repeat([]byte("x"), 2<<20))}) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(protocol.Envelope{ + Version: protocol.Version, + Type: "collection_bootstrap", + Payload: payload, + }) + if err != nil { + t.Fatal(err) + } + + writeErr := make(chan error, 1) + go func() { + defer server.Close() + var header [4]byte + binary.BigEndian.PutUint32(header[:], uint32(len(raw))) + if _, err := server.Write(header[:]); err != nil { + writeErr <- err + return + } + _, err := server.Write(raw) + writeErr <- err + }() + + message, err := client.Read() + if err != nil { + t.Fatal(err) + } + if message.Type != "collection_bootstrap" { + t.Fatalf("message type = %q, want collection_bootstrap", message.Type) + } + if err := <-writeErr; err != nil { + t.Fatal(err) + } +} + +func TestConnectFromEnvironmentAuthenticatesTCPTransport(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + t.Setenv("STRIX_TUI_ADDR", listener.Addr().String()) + t.Setenv("STRIX_TUI_TOKEN", "one-use-token") + t.Setenv("STRIX_TUI_FD", "") + + serverErr := make(chan error, 1) + go func() { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + serverErr <- acceptErr + return + } + defer connection.Close() + token := make([]byte, len("one-use-token")) + if _, readErr := io.ReadFull(connection, token); readErr != nil { + serverErr <- readErr + return + } + if string(token) != "one-use-token" { + serverErr <- os.ErrPermission + return + } + raw, marshalErr := json.Marshal(protocol.Envelope{ + Version: protocol.Version, + Type: "hello", + Payload: json.RawMessage(`{}`), + }) + if marshalErr != nil { + serverErr <- marshalErr + return + } + var header [4]byte + binary.BigEndian.PutUint32(header[:], uint32(len(raw))) + if writeErr := writeAll(connection, append(header[:], raw...)); writeErr != nil { + serverErr <- writeErr + return + } + serverErr <- nil + }() + + client, err := ConnectFromEnvironment() + if err != nil { + t.Fatal(err) + } + defer client.Close() + message, err := client.Read() + if err != nil { + t.Fatal(err) + } + if message.Type != "hello" { + t.Fatalf("message type = %q, want hello", message.Type) + } + if err := <-serverErr; err != nil { + t.Fatal(err) + } + if os.Getenv("STRIX_TUI_ADDR") != "" || os.Getenv("STRIX_TUI_TOKEN") != "" { + t.Fatal("TCP transport credentials were not removed from the environment") + } +} + +func TestConnectFromEnvironmentRequiresCompleteTransport(t *testing.T) { + t.Setenv("STRIX_TUI_FD", "") + t.Setenv("STRIX_TUI_ADDR", "127.0.0.1:1") + t.Setenv("STRIX_TUI_TOKEN", "") + + _, err := ConnectFromEnvironment() + if err == nil || !strings.Contains(err.Error(), "STRIX_TUI_ADDR and STRIX_TUI_TOKEN") { + t.Fatalf("error = %v, want missing transport error", err) + } +} + +func TestConnectFromEnvironmentPrefersInheritedDescriptor(t *testing.T) { + t.Setenv("STRIX_TUI_FD", "not-a-number") + t.Setenv("STRIX_TUI_ADDR", "127.0.0.1:1") + t.Setenv("STRIX_TUI_TOKEN", "token") + + _, err := ConnectFromEnvironment() + if err == nil || !strings.Contains(err.Error(), "invalid STRIX_TUI_FD") { + t.Fatalf("error = %v, want inherited descriptor parse error", err) + } +} diff --git a/strix/interface/tui/internal/app/frame_bench_test.go b/strix/interface/tui/internal/app/frame_bench_test.go new file mode 100644 index 00000000..404a71dd --- /dev/null +++ b/strix/interface/tui/internal/app/frame_bench_test.go @@ -0,0 +1,98 @@ +package app + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/color" + "image/png" + "testing" + + "github.com/usestrix/strix/tui/internal/protocol" + "github.com/usestrix/strix/tui/internal/render" +) + +func benchImageDataURI(b *testing.B, w, h int) string { + b.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 0x40, A: 0xff}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + b.Fatal(err) + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +// BenchmarkChatContentWithImages measures a frame render for a trace holding +// many inline images, the case that made the TUI unresponsive. +func BenchmarkChatContentWithImages(b *testing.B) { + supported := render.KittyGraphicsSupported + render.KittyGraphicsSupported = func() bool { return true } + b.Cleanup(func() { render.KittyGraphicsSupported = supported }) + + model := New(nil) + model.width, model.height = 130, 40 + model.showSplash = false + model.ready = true + events := make([]protocol.Event, 0, 20) + for i := range 20 { + events = append(events, protocol.Event{ + ID: fmt.Sprintf("%d", i), AgentID: "one", Type: "tool", + Data: map[string]any{ + "tool_name": "view_image", + "args": map[string]any{"path": fmt.Sprintf("/tmp/shot-%d.png", i)}, + "result": benchImageDataURI(b, 2000+i, 1400), + "status": "completed", + }, + }) + } + model.snapshot = protocol.Snapshot{ + Agents: []protocol.Agent{{ID: "one", Name: "Agent", Status: "running"}}, + Events: events, + } + model.resizeViewport() + + b.ResetTimer() + for b.Loop() { + _ = model.View() + } +} + +func BenchmarkFrameWithImagesAfterUpdate(b *testing.B) { + supported := render.KittyGraphicsSupported + render.KittyGraphicsSupported = func() bool { return true } + b.Cleanup(func() { render.KittyGraphicsSupported = supported }) + + model := New(nil) + model.width, model.height = 130, 40 + model.showSplash = false + model.ready = true + events := make([]protocol.Event, 0, 20) + for i := range 20 { + events = append(events, protocol.Event{ + ID: fmt.Sprintf("%d", i), AgentID: "one", Type: "tool", + Data: map[string]any{ + "tool_name": "view_image", + "args": map[string]any{"path": fmt.Sprintf("/tmp/shot-%d.png", i)}, + "result": benchImageDataURI(b, 2000+i, 1400), + "status": "completed", + }, + }) + } + model.snapshot = protocol.Snapshot{ + Agents: []protocol.Agent{{ID: "one", Name: "Agent", Status: "running"}}, + Events: events, + } + model.resizeViewport() + + b.ResetTimer() + for b.Loop() { + model.refreshViewport() + _ = model.View() + } +} diff --git a/strix/interface/tui/internal/app/input_test.go b/strix/interface/tui/internal/app/input_test.go new file mode 100644 index 00000000..c7f74828 --- /dev/null +++ b/strix/interface/tui/internal/app/input_test.go @@ -0,0 +1,216 @@ +package app + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/usestrix/strix/tui/internal/protocol" +) + +func inputModel(t *testing.T) Model { + t.Helper() + model := New(nil) + model.showSplash = false + model.ready = true + model.width, model.height = 130, 40 + model.resizeViewport() + return model +} + +func TestInputGrowsWithContentUpToCap(t *testing.T) { + model := inputModel(t) + // The live composer opens at a single row, out of the trace's way. + if got := model.input.Height(); got != 1 { + t.Fatalf("empty composer height = %d, want 1", got) + } + model.input.SetValue(strings.Repeat("line\n", 4) + "line") + model.resizeViewport() + if got := model.input.Height(); got != 5 { + t.Fatalf("5-line composer height = %d, want 5", got) + } + model.input.SetValue(strings.Repeat("line\n", 19) + "line") + model.resizeViewport() + if got := model.input.Height(); got != maxInputLines { + t.Fatalf("20-line composer height = %d, want %d", got, maxInputLines) + } +} + +// The launch composer opens with room to breathe; the live one stays a single +// row until there is something to show, as it always has. +func TestComposerOpeningHeightPerMode(t *testing.T) { + live := inputModel(t) + if got := live.input.Height(); got != 1 { + t.Fatalf("live composer opens at %d rows, want 1", got) + } + + setup := inputModel(t) + setup.snapshot.SetupMode = true + setup.resizeViewport() + if got := setup.input.Height(); got != minInputLines { + t.Fatalf("launch composer opens at %d rows, want %d", got, minInputLines) + } +} + +// A prompt with no newline in it still has to grow the composer once it wraps. +func TestInputGrowsWithSoftWrappedLine(t *testing.T) { + for _, setup := range []bool{false, true} { + model := inputModel(t) + model.snapshot.SetupMode = setup + model.resizeViewport() + floor, _ := model.composerBounds() + if got := model.input.Height(); got != floor { + t.Fatalf("setup=%v: empty composer height = %d, want floor %d", setup, got, floor) + } + width := model.input.Width() + model.input.SetValue(strings.Repeat("x", width*5-1)) + model.resizeViewport() + // Five rows of text; the textarea adds a trailing row when the last one + // is full, so the cursor stays visible. + if got := model.input.Height(); got < 5 || got > 6 { + t.Fatalf("setup=%v: wrapped composer height = %d, want 5 or 6", setup, got) + } + model.input.SetValue(strings.Repeat("x", width*maxInputLines*2)) + model.resizeViewport() + if got := model.input.Height(); got != maxInputLines { + t.Fatalf("setup=%v: overlong composer height = %d, want %d", setup, got, maxInputLines) + } + } +} + +// The composer never takes more than a third of a short terminal. +func TestInputHeightCappedOnShortTerminal(t *testing.T) { + model := inputModel(t) + model.width, model.height = 130, 15 + model.input.SetValue(strings.Repeat("line\n", 10) + "line") + model.resizeViewport() + if got := model.input.Height(); got != 5 { + t.Fatalf("composer height on a 15-row terminal = %d, want 5", got) + } +} + +// The rendered frame must be exactly the terminal size at every step of +// typing. A composer that renders one cell too wide gets re-wrapped into an +// extra row, which pushes the frame past the bottom of the terminal and makes +// the screen jump at wrap points. +func TestFrameFitsTerminalWhileTyping(t *testing.T) { + sizes := [][2]int{{130, 40}, {100, 30}, {80, 24}} + for _, setup := range []bool{false, true} { + for _, size := range sizes { + model := New(nil) + model.showSplash, model.ready, model.focus = false, true, focusInput + model.width, model.height = size[0], size[1] + model.snapshot = protocol.Snapshot{SetupMode: setup, Model: "anthropic/claude-sonnet-4-5"} + if !setup { + model.snapshot.Agents = []protocol.Agent{{ID: "a1", Name: "recon", Status: "running"}} + } + model.resizeViewport() + for i, r := range strings.Repeat("alpha bravo charlie delta echo foxtrot ", 6) { + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + model = updated.(Model) + rows := strings.Split(model.View(), "\n") + if len(rows) != size[1] { + t.Fatalf("setup=%v %v: after %d chars the frame is %d rows, want %d", + setup, size, i+1, len(rows), size[1]) + } + for row, line := range rows { + if width := lipgloss.Width(line); width != size[0] { + t.Fatalf("setup=%v %v: after %d chars row %d is %d cells, want %d", + setup, size, i+1, row, width, size[0]) + } + } + } + } + } +} + +// The launch column is anchored: growing the composer must not walk the +// wordmark and the prompt up the screen. +func TestLaunchColumnHoldsStillWhileComposerGrows(t *testing.T) { + model := New(nil) + model.showSplash, model.ready, model.focus = false, true, focusInput + model.width, model.height = 130, 40 + model.snapshot = protocol.Snapshot{SetupMode: true} + model.resizeViewport() + composerRow := func() int { + for row, line := range strings.Split(ansi.Strip(model.View()), "\n") { + if strings.Contains(line, "β•­") { + return row + } + } + return -1 + } + want := composerRow() + for i, r := range strings.Repeat("alpha bravo charlie delta echo ", 12) { + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + model = updated.(Model) + if got := composerRow(); got != want { + t.Fatalf("after %d chars the composer moved to row %d, want %d (height %d)", + i+1, got, want, model.input.Height()) + } + } + if model.input.Height() < 5 { + t.Fatalf("composer only grew to %d rows; the test is not exercising growth", model.input.Height()) + } +} + +func TestCtrlJInsertsNewline(t *testing.T) { + model := inputModel(t) + model.input.SetValue("hello") + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) + model = updated.(Model) + if got := model.input.Value(); got != "hello\n" { + t.Fatalf("value after ctrl+j = %q, want %q", got, "hello\n") + } +} + +func TestEnterSubmitsTrimmedMultilineMessage(t *testing.T) { + model := inputModel(t) + model.input.SetValue("first\nsecond ") + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(Model) + if got := model.input.Value(); got != "" { + t.Fatalf("composer not cleared after submit: %q", got) + } + if got := model.input.Height(); got != 1 { + t.Fatalf("composer height after submit = %d, want 1", got) + } +} + +func TestDragSelectionInInputCopiesText(t *testing.T) { + model := inputModel(t) + copied := "" + original := writeClipboard + writeClipboard = func(text string) error { + copied = text + return nil + } + defer func() { writeClipboard = original }() + + model.input.SetValue("copy me please") + model.resizeViewport() + top := model.inputTop() + + updated, _ := model.updateMouse(tea.MouseMsg{ + X: 4, Y: top + 1, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, + }) + model = updated.(Model) + if !model.selection.dragging || model.selection.region != regionInput { + t.Fatalf("press in the composer did not start an input selection: %+v", model.selection) + } + updated, _ = model.updateMouse(tea.MouseMsg{X: 10, Y: top + 1, Action: tea.MouseActionMotion}) + model = updated.(Model) + updated, cmd := model.updateMouse(tea.MouseMsg{Action: tea.MouseActionRelease}) + model = updated.(Model) + if cmd == nil { + t.Fatal("input selection release produced no copy command") + } + if msg, ok := cmd().(selectionCopiedMsg); !ok || msg.err != nil { + t.Fatalf("unexpected copy result: %#v", cmd()) + } + if copied != "copy me" { + t.Fatalf("copied %q, want %q", copied, "copy me") + } +} diff --git a/strix/interface/tui/internal/app/model.go b/strix/interface/tui/internal/app/model.go new file mode 100644 index 00000000..f2e876e4 --- /dev/null +++ b/strix/interface/tui/internal/app/model.go @@ -0,0 +1,405 @@ +package app + +import ( + "fmt" + "strings" + "time" + + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/usestrix/strix/tui/internal/protocol" +) + +type wireMsg protocol.Envelope +type wireErrMsg struct{ err error } +type sentMsg struct { + requestID string + command string + collection string + err error +} +type splashTickMsg time.Time +type sweepTickMsg time.Time +type vulnerabilityCopiedMsg struct{ err error } + +var writeClipboard = clipboard.WriteAll + +type collectionAssembly struct { + kind string + revision int + baseRevision int + cursor int + agents []protocol.Agent + events []protocol.Event + findings []map[string]any + operations []protocol.CollectionOperation + ids map[string]bool +} + +// appVersion is the package version string shown on the splash and stats panel. +// It is set by main from the STRIX_VERSION env var (see go_tui.py), matching +// Python's get_package_version() which reads the installed "strix-agent" version +// and falls back to "dev". +var appVersion = "dev" + +// SetVersion overrides the displayed version; empty values are ignored so the +// "dev" fallback survives when the launcher does not provide one. +func SetVersion(v string) { + if strings.TrimSpace(v) != "" { + appVersion = strings.TrimSpace(v) + } +} + +type modalMode int + +const ( + modalNone modalMode = iota + modalHelp + modalQuit + modalStop + modalConfirmMount + modalVulnerability +) + +type focusMode int + +const ( + focusInput focusMode = iota + focusChat + focusAgents + focusVulnerabilities +) + +type scrollbarTarget int + +const ( + scrollbarNone scrollbarTarget = iota + scrollbarTrace + scrollbarAgents + scrollbarFindings +) + +type Model struct { + client *Client + width, height int + snapshot protocol.Snapshot + input textarea.Model + viewport viewport.Model + viewportContent string + vulnViewport viewport.Model + modal modalMode + focus focusMode + options []string + filtered []string + cursor int + collapsedAgents map[string]bool + expandedEvents map[string]bool + blockCache map[string]renderedBlock + eventSpans []eventSpan + setupLog []string + pendingPrompt string + errorText string + fatalError error + selectedAgent int + selectedVuln int + agentOffset int + vulnOffset int + modalChoice int + ready bool + quitting bool + showSplash bool + splashStarted time.Time + splashFrame int + sweepFrame int + budgetPauseNotified bool + followOutput bool + selection selectionState + toast string + toastID int + draggingScrollbar scrollbarTarget + stateRevision int + collectionRevisions map[string]int + collectionAssemblies map[string]*collectionAssembly + resyncRequested map[string]bool + resyncRequests map[string]string + seenMessages map[string]bool + vulnerabilityCopied bool + vulnerabilityCopyError string +} + +var ( + green = lipgloss.Color("#22c55e") + brightGreen = lipgloss.Color("#4ade80") + blue = lipgloss.Color("#3b82f6") + lightBlue = lipgloss.Color("#60a5fa") + red = lipgloss.Color("#ef4444") + orange = lipgloss.Color("#ea580c") + amber = lipgloss.Color("#d97706") + white = lipgloss.Color("#fafaf9") + brightWhite = lipgloss.Color("#ffffff") + textColor = lipgloss.Color("#d4d4d4") + dim = lipgloss.Color("#737373") + mid = lipgloss.Color("#a3a3a3") + dark = lipgloss.Color("#333333") + black = lipgloss.Color("#000000") +) + +// Agent tree colors: a uniform label, dim guides, and a filled block cursor. +const ( + treeLabel = lipgloss.Color("#e7e5e4") + treeGuide = lipgloss.Color("#4f4f4f") + treeCursorFg = lipgloss.Color("#ddedf9") + treeCursorBg = lipgloss.Color("#0178d4") +) + +// Scrollbar thumbs. Each panel keeps its own, and the track stays blank so a +// scrollable panel does not gain a visible rule down its edge. +const ( + thumbTrace = lipgloss.Color("#1a1a1a") + thumbAgents = lipgloss.Color("#404040") + thumbFindings = lipgloss.Color("#333333") +) + +// Composer placeholders. The launch screen falls back to the short prompt when +// the column is too narrow to show the full one without clipping it. +const ( + setupPlaceholder = "Describe what to test, or name a target" + setupPlaceholderShort = "What should Strix test?" + chatPlaceholder = "Send a message" +) + +// The composer opens at minInputLines rows for breathing room and grows with +// its content up to maxInputLines. +const ( + minInputLines = 3 + maxInputLines = 8 +) + +// newChatInput builds the multi-line chat composer. Enter submits (handled by +// the update loop before the textarea sees it); Shift/Alt+Enter and Ctrl+J +// insert a newline. +func newChatInput() textarea.Model { + input := textarea.New() + input.ShowLineNumbers = false + input.CharLimit = 4096 + input.MaxHeight = maxInputLines + input.SetHeight(1) + input.KeyMap.InsertNewline = key.NewBinding( + key.WithKeys("shift+enter", "alt+enter", "ctrl+j"), + key.WithHelp("shift+enter", "insert newline"), + ) + plain := lipgloss.NewStyle() + text := lipgloss.NewStyle().Foreground(textColor) + placeholder := lipgloss.NewStyle().Foreground(lipgloss.Color("#525252")) + for _, style := range []*textarea.Style{&input.FocusedStyle, &input.BlurredStyle} { + style.Base = plain + style.CursorLine = text + style.EndOfBuffer = plain + style.Placeholder = placeholder + style.Text = text + } + input.FocusedStyle.Prompt = lipgloss.NewStyle().Bold(true).Foreground(green) + input.BlurredStyle.Prompt = lipgloss.NewStyle().Foreground(dim) + input.SetPromptFunc(2, func(lineIdx int) string { + if lineIdx == 0 { + return "> " + } + return " " + }) + input.Cursor.Style = lipgloss.NewStyle().Foreground(green) + return input +} + +// composerBounds returns the floor and ceiling row counts for the composer at +// the current terminal height. A short terminal shrinks the ceiling so a long +// prompt cannot crowd out everything above it. +// +// Only the launch screen opens taller than a single row: there the composer is +// the whole screen and wants breathing room, while during a scan it sits under +// the trace and stays out of the way until there is something to show. +func (m Model) composerBounds() (floor, ceiling int) { + ceiling = maxInputLines + if m.height > 0 { + ceiling = max(minInputLines, min(maxInputLines, m.height/3)) + } + floor = 1 + if m.snapshot.SetupMode { + floor = min(minInputLines, ceiling) + } + return floor, ceiling +} + +// syncInputHeight grows or shrinks the composer with its content, between the +// floor and ceiling. +func (m *Model) syncInputHeight() { + floor, ceiling := m.composerBounds() + m.input.SetHeight(max(floor, min(composerHeight(m.input), ceiling))) +} + +// composerHeight is how many rows the composer needs to show all of its +// content, capped at maxInputLines. Soft-wrapped rows count: a single long +// line still grows the box. LineCount only counts hard newlines, and the +// wrapped height the textarea does report covers just the line the cursor is +// on, so a scratch copy measures each line with the composer's own wrapping. +func composerHeight(input textarea.Model) int { + probe, rows := input, 0 + for _, line := range strings.Split(input.Value(), "\n") { + // A line narrower than the text column cannot wrap, which is the case + // for nearly every keystroke; only measure the ones that might. + if ansi.StringWidth(line) < input.Width() { + rows++ + } else { + probe.SetValue(line) + rows += probe.LineInfo().Height + } + if rows >= maxInputLines { + return maxInputLines + } + } + return max(1, rows) +} + +func New(client *Client) Model { + input := newChatInput() + input.Placeholder = setupPlaceholder + input.Focus() + return Model{ + client: client, input: input, viewport: viewport.New(80, 20), vulnViewport: viewport.New(80, 20), + collapsedAgents: map[string]bool{}, expandedEvents: map[string]bool{}, blockCache: map[string]renderedBlock{}, showSplash: true, splashStarted: time.Now(), followOutput: true, + collectionRevisions: map[string]int{}, collectionAssemblies: map[string]*collectionAssembly{}, resyncRequested: map[string]bool{}, resyncRequests: map[string]string{}, + seenMessages: map[string]bool{}, + } +} + +func (m Model) Init() tea.Cmd { return tea.Batch(readWire(m.client), splashTick(), sweepTick()) } + +// splashTick drives the splash "Starting Strix Agent" shimmer at Python's 0.1s cadence. +func splashTick() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return splashTickMsg(t) }) +} + +// sweepTick drives the running-status sweep animation at Python's 0.06s cadence. +func sweepTick() tea.Cmd { + return tea.Tick(60*time.Millisecond, func(t time.Time) tea.Msg { return sweepTickMsg(t) }) +} + +func readWire(client *Client) tea.Cmd { + return func() tea.Msg { + envelope, err := client.Read() + if err != nil { + return wireErrMsg{err} + } + return wireMsg(envelope) + } +} + +func send(client *Client, command string, payload any) tea.Cmd { + return func() tea.Msg { + requestID, err := client.Send(command, payload) + collection := "" + if values, ok := payload.(map[string]any); ok { + collection, _ = values["collection"].(string) + } + return sentMsg{requestID: requestID, command: command, collection: collection, err: err} + } +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case splashTickMsg: + m.splashFrame++ + if m.showSplash && time.Since(m.splashStarted) >= 4500*time.Millisecond { + m.showSplash = false + } + return m, splashTick() + case sweepTickMsg: + m.sweepFrame++ + return m, sweepTick() + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.resizeViewport() + m.resizeVulnerabilityViewport() + m.ensureAgentVisible() + m.ensureVulnerabilityVisible() + case wireErrMsg: + if !m.quitting { + m.errorText = "Backend disconnected: " + msg.err.Error() + m.fatalError = fmt.Errorf("backend disconnected: %w", msg.err) + } + return m, tea.Quit + case wireMsg: + envelope := protocol.Envelope(msg) + if envelope.Version != protocol.Version { + m.errorText = fmt.Sprintf("Protocol mismatch: backend=%d client=%d", envelope.Version, protocol.Version) + m.fatalError = fmt.Errorf("protocol mismatch: backend=%d client=%d", envelope.Version, protocol.Version) + return m, tea.Quit + } + if cmd := m.handleEnvelope(envelope); cmd != nil { + cmds = append(cmds, cmd) + } + cmds = append(cmds, readWire(m.client)) + case sentMsg: + if msg.err != nil { + m.errorText = msg.err.Error() + if msg.command == "collection.resync" && msg.collection != "" { + m.resyncRequested[msg.collection] = false + } + } else if msg.command == "collection.resync" && msg.requestID != "" && msg.collection != "" { + m.resyncRequests[msg.requestID] = msg.collection + } + case selectionCopiedMsg: + text := "Copied to clipboard" + if msg.err != nil { + text = "Copy failed: " + msg.err.Error() + } + return m, m.showToast(text) + case toastExpiredMsg: + if msg.id == m.toastID { + m.toast = "" + if !m.selection.dragging { + m.selection.active = false + } + } + return m, nil + case vulnerabilityCopiedMsg: + m.vulnerabilityCopied = msg.err == nil + m.vulnerabilityCopyError = "" + if msg.err != nil { + m.vulnerabilityCopyError = msg.err.Error() + } + return m, nil + case tea.KeyMsg: + if m.showSplash { + switch msg.String() { + case "ctrl+c", "ctrl+q", "q", "esc": + m.quitting = true + return m, tea.Batch(send(m.client, "app.quit", map[string]any{}), tea.Quit) + } + m.showSplash = false + return m, nil + } + if m.modal != modalNone { + return m.updateModal(msg) + } + return m.updateMain(msg) + case tea.MouseMsg: + if m.showSplash || !m.ready { + return m, nil + } + return m.updateMouse(msg) + } + var cmd tea.Cmd + if m.modal == modalNone { + m.input, cmd = m.input.Update(msg) + } + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func (m Model) FatalError() error { return m.fatalError } diff --git a/strix/interface/tui/internal/app/model_test.go b/strix/interface/tui/internal/app/model_test.go new file mode 100644 index 00000000..901db23f --- /dev/null +++ b/strix/interface/tui/internal/app/model_test.go @@ -0,0 +1,1171 @@ +package app + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/usestrix/strix/tui/internal/protocol" +) + +type recordingConn struct{ bytes.Buffer } + +func (c *recordingConn) Close() error { return nil } + +func commandFromCmd(t *testing.T, cmd tea.Cmd, connection *recordingConn) protocol.Envelope { + t.Helper() + if cmd == nil { + t.Fatal("expected command") + } + msg := cmd() + if sent, ok := msg.(sentMsg); !ok || sent.err != nil { + t.Fatalf("command failed: %#v", msg) + } + raw := connection.Bytes() + if len(raw) < 4 { + t.Fatalf("short command frame: %d bytes", len(raw)) + } + size := int(binary.BigEndian.Uint32(raw[:4])) + if len(raw) != size+4 { + t.Fatalf("command frame size = %d, want %d", len(raw), size+4) + } + var envelope protocol.Envelope + if err := json.Unmarshal(raw[4:], &envelope); err != nil { + t.Fatal(err) + } + return envelope +} + +func newCommandTestModel(t *testing.T) (Model, *recordingConn) { + t.Helper() + connection := &recordingConn{} + return New(&Client{conn: connection}), connection +} + +func handleCommandResult(t *testing.T, model *Model, command string, result any) tea.Cmd { + t.Helper() + resultPayload, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(protocol.CommandResult{OK: true, Command: command, Result: resultPayload}) + if err != nil { + t.Fatal(err) + } + if model.client == nil { + model.client = newClient(&recordingConn{}) + } + if model.client.pending == nil { + model.client.pending = map[string]string{} + model.client.pendingByKey = map[string]string{} + model.client.requestKeyByID = map[string]string{} + } + requestID := fmt.Sprintf("test-%d", len(model.client.pending)+1) + model.client.pending[requestID] = command + model.client.pendingByKey[command] = requestID + model.client.requestKeyByID[requestID] = command + return model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "command_result", RequestID: requestID, Payload: payload}) +} + +func stateEnvelope(t *testing.T, revision int, state protocol.Snapshot) protocol.Envelope { + t.Helper() + payload, err := json.Marshal(protocol.StateUpdate{Revision: revision, State: state}) + if err != nil { + t.Fatal(err) + } + return protocol.Envelope{Version: protocol.Version, Type: "state", Payload: payload} +} + +func rawJSON(t *testing.T, value any) json.RawMessage { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw +} + +func bootstrapEnvelope(t *testing.T, collection string, revision int, items ...any) protocol.Envelope { + t.Helper() + rawItems := make([]json.RawMessage, 0, len(items)) + for _, item := range items { + rawItems = append(rawItems, rawJSON(t, item)) + } + payload := protocol.CollectionBootstrap{ + Collection: collection, Revision: revision, Cursor: 0, NextCursor: len(rawItems), Done: true, Items: rawItems, + } + return protocol.Envelope{Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, payload)} +} + +func TestBackendDisconnectBecomesFatalUnlessUserIsQuitting(t *testing.T) { + model := New(nil) + updated, cmd := model.Update(wireErrMsg{err: fmt.Errorf("socket closed")}) + result := updated.(Model) + if cmd == nil || result.FatalError() == nil { + t.Fatalf("backend disconnect was not fatal: cmd=%v error=%v", cmd, result.FatalError()) + } + + model = New(nil) + model.quitting = true + updated, _ = model.Update(wireErrMsg{err: fmt.Errorf("socket closed")}) + if quitting := updated.(Model); quitting.FatalError() != nil { + t.Fatalf("intentional quit became fatal: %v", quitting.FatalError()) + } +} + +func TestCollectionBootstrapChunksAndVersionedDelta(t *testing.T) { + model := New(nil) + first := protocol.Event{ID: "event-1", Version: 0, Type: "chat", AgentID: "agent", Data: map[string]any{"content": "one"}} + second := protocol.Event{ID: "event-2", Version: 0, Type: "chat", AgentID: "agent", Data: map[string]any{"content": "two"}} + + firstChunk := protocol.CollectionBootstrap{ + Collection: "events", Revision: 1, Cursor: 0, NextCursor: 1, Items: []json.RawMessage{rawJSON(t, first)}, + } + model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, firstChunk)}) + if len(model.snapshot.Events) != 0 { + t.Fatal("partial bootstrap mutated installed events") + } + lastChunk := protocol.CollectionBootstrap{ + Collection: "events", Revision: 1, Cursor: 1, NextCursor: 2, Done: true, Items: []json.RawMessage{rawJSON(t, second)}, + } + model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, lastChunk)}) + if len(model.snapshot.Events) != 2 || model.collectionRevisions["events"] != 1 { + t.Fatalf("bootstrap was not installed: %#v revisions=%#v", model.snapshot.Events, model.collectionRevisions) + } + + first.Version = 1 + first.Data["content"] = "updated" + delta := protocol.CollectionDelta{ + Collection: "events", BaseRevision: 1, Revision: 2, Cursor: 0, NextCursor: 1, Done: true, + Operations: []protocol.CollectionOperation{{Op: "upsert", Item: rawJSON(t, first)}}, + } + model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, delta)}) + if model.snapshot.Events[0].Version != 1 || model.snapshot.Events[0].Data["content"] != "updated" || model.collectionRevisions["events"] != 2 { + t.Fatalf("delta was not applied: %#v", model.snapshot.Events[0]) + } + + deleteDelta := protocol.CollectionDelta{ + Collection: "events", BaseRevision: 2, Revision: 3, Cursor: 0, NextCursor: 1, Done: true, + Operations: []protocol.CollectionOperation{{Op: "delete", ID: "event-2"}}, + } + model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, deleteDelta)}) + if len(model.snapshot.Events) != 1 || model.snapshot.Events[0].ID != "event-1" || model.collectionRevisions["events"] != 3 { + t.Fatalf("delete delta was not applied: %#v", model.snapshot.Events) + } +} + +func TestCollectionMismatchRequestsOneResync(t *testing.T) { + connection := &recordingConn{} + model := New(newClient(connection)) + model.collectionRevisions["events"] = 4 + bad := protocol.CollectionDelta{ + Collection: "events", BaseRevision: 2, Revision: 3, Cursor: 0, NextCursor: 0, Done: true, + } + + cmd := model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, bad)}) + if cmd == nil { + t.Fatal("revision mismatch did not request a resync") + } + message := cmd() + if sent, ok := message.(sentMsg); !ok || sent.err != nil || sent.command != "collection.resync" { + t.Fatalf("resync send = %#v", message) + } + if retry := model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, bad)}); retry != nil { + t.Fatal("same mismatch submitted more than one resync") + } + if model.collectionRevisions["events"] != 4 { + t.Fatal("mismatched delta mutated collection revision") + } +} + +func TestAgentsCollectionPreservesSelectedIDAcrossUpsertsAndDeletes(t *testing.T) { + model := New(nil) + model.handleEnvelope(bootstrapEnvelope(t, "agents", 1, + protocol.Agent{ID: "root", Name: "Root", Status: "running"}, + protocol.Agent{ID: "selected", Name: "Selected", Status: "running"}, + protocol.Agent{ID: "other", Name: "Other", Status: "waiting"}, + )) + model.selectedAgent = 1 + + updated := protocol.Agent{ID: "selected", Name: "Selected updated", Status: "budget_paused"} + delta := protocol.CollectionDelta{ + Collection: "agents", BaseRevision: 1, Revision: 2, Cursor: 0, NextCursor: 2, Done: true, + Operations: []protocol.CollectionOperation{ + {Op: "delete", ID: "root"}, + {Op: "upsert", Item: rawJSON(t, updated)}, + }, + } + model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, delta)}) + if got := model.snapshot.Agents[model.selectedAgent].ID; got != "selected" { + t.Fatalf("selected agent changed to %q after delta", got) + } + if model.snapshot.Agents[model.selectedAgent].Status != "budget_paused" { + t.Fatalf("agent upsert was not applied: %#v", model.snapshot.Agents[model.selectedAgent]) + } + + deleteSelected := protocol.CollectionDelta{ + Collection: "agents", BaseRevision: 2, Revision: 3, Cursor: 0, NextCursor: 1, Done: true, + Operations: []protocol.CollectionOperation{{Op: "delete", ID: "selected"}}, + } + model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, deleteSelected)}) + if len(model.snapshot.Agents) != 1 || model.snapshot.Agents[model.selectedAgent].ID != "other" { + t.Fatalf("selected-agent delete did not fall back safely: %#v", model.snapshot.Agents) + } +} + +// Typing a slash must not surface a command list; the start screen takes prompts +// and targets only. +func TestSetupOffersNoSlashCommands(t *testing.T) { + model := New(nil) + model.width, model.height = 100, 50 + model.showSplash = false + model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{SetupMode: true, ScanState: "setup"})) + + model.input.SetValue("/") + model.resizeViewport() + view := ansi.Strip(model.View()) + for _, gone := range []string{"/target", "/start", "/clear", "/prompt", "/quit", "/help"} { + if strings.Contains(view, gone) { + t.Fatalf("a slash command menu still appears for %q: %s", gone, view) + } + } +} + +func TestSetupUsesDedicatedStartScreen(t *testing.T) { + model := New(nil) + model.width, model.height = 130, 34 + model.showSplash = false + state := protocol.Snapshot{ + SetupMode: true, + ScanState: "setup", + Model: "gpt-5.4", + Targets: []string{"/workspace/source", "https://example.com"}, + Instruction: "focus on access control", + ScanMode: "quick", + MaxBudgetUSD: floatPointer(12.5), + MaxTurns: 275, + ScopeMode: "diff", + DiffBase: "origin/main", + Agents: []protocol.Agent{{ID: "hidden", Name: "SETUP_SHOULD_HIDE_AGENT", Status: "running"}}, + } + model.handleEnvelope(stateEnvelope(t, 1, state)) + + view := model.View() + for _, want := range []string{ + "gpt-5.4", + "/workspace/source", + "https://example.com", + } { + if !strings.Contains(view, want) { + t.Fatalf("start screen is missing %q: %s", want, view) + } + } + if strings.Contains(view, "SETUP_SHOULD_HIDE_AGENT") { + t.Fatalf("live scan sidebar appeared on the start screen: %s", view) + } +} + +func floatPointer(value float64) *float64 { return &value } + +func TestStartedSnapshotTransitionsToLiveView(t *testing.T) { + model := New(nil) + model.width, model.height = 130, 34 + model.showSplash = false + model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{SetupMode: true, ScanState: "setup"})) + if view := model.View(); !strings.Contains(view, setupPlaceholder) { + t.Fatalf("setup snapshot did not show the start screen: %s", view) + } + + runningState := protocol.Snapshot{ + SetupMode: false, + ScanStarted: true, + ScanState: "running", + } + model.handleEnvelope(stateEnvelope(t, 2, runningState)) + model.handleEnvelope(bootstrapEnvelope(t, "agents", 1, protocol.Agent{ID: "one", Name: "LIVE_AGENT", Status: "running"})) + view := model.View() + if !strings.Contains(view, "LIVE_AGENT") || !strings.Contains(view, "Send a message") || strings.Contains(view, "Configure your pentest") { + t.Fatalf("started snapshot did not switch to the live view: %s", view) + } + if model.input.Placeholder != "Send a message" { + t.Fatalf("live input placeholder was not updated: %q", model.input.Placeholder) + } +} + +func TestSetupStartScreenFitsNarrowTerminal(t *testing.T) { + model := New(nil) + model.width, model.height = 40, 18 + model.showSplash = false + model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{SetupMode: true, ScanState: "setup"})) + model.input.SetValue("/") + model.resizeViewport() + + view := ansi.Strip(model.viewInner()) + // A narrow terminal falls back to the plain wordmark, but the launch screen + // never gives up its identity entirely. + topRow := ansi.Strip(strings.SplitN(wordmark(), "\n", 2)[0]) + if !strings.Contains(view, topRow) && !strings.Contains(view, "STRIX") { + t.Fatalf("narrow start screen logo is missing: %s", view) + } + lines := strings.Split(view, "\n") + if len(lines) > model.height { + t.Fatalf("start screen height %d exceeds terminal height %d", len(lines), model.height) + } + for _, line := range lines { + if width := lipgloss.Width(line); width > model.width { + t.Fatalf("start screen line width %d exceeds terminal width %d: %q", width, model.width, ansi.Strip(line)) + } + } +} + +// A leading slash is ordinary prompt text now: there are no commands to match, +// so it must reach the scan as written rather than being rejected. +func TestLeadingSlashIsPromptTextNotACommand(t *testing.T) { + connection := &recordingConn{} + model := New(&Client{conn: connection}) + model.snapshot = protocol.Snapshot{SetupMode: true} + model.focus = focusInput + model.input.SetValue("/etc/passwd is world readable, check it") + + updated, cmd := model.updateMain(tea.KeyMsg{Type: tea.KeyEnter}) + result := updated.(Model) + if cmd == nil { + t.Fatal("enter did not submit") + } + types := commandTypes(drainCommands(t, cmd, connection)) + if !contains(types, "setup.start") { + t.Fatalf("a slash-leading prompt did not launch a scan: %v", types) + } + // The path is read as a target and the sentence as the instruction. + if !contains(types, "setup.add_target") || !contains(types, "setup.set_instruction") { + t.Fatalf("slash-leading prompt was not split into target and instruction: %v", types) + } + for _, line := range result.setupLog { + if strings.Contains(ansi.Strip(line), "Unknown command") { + t.Fatalf("a slash-leading prompt was treated as a command: %#v", result.setupLog) + } + } +} + +// The composer is cleared on submit and the prompt is not echoed into the log. +func TestSubmittedPromptIsNotEchoedInOutput(t *testing.T) { + model := New(nil) + model.snapshot.SetupMode = true + updated, _ := model.submitSetupPrompt("secret instruction") + result := updated.(Model) + if content := result.setupContent(); strings.Contains(content, "secret instruction") { + t.Fatalf("submitted prompt leaked into output: %s", content) + } +} + +func TestStateMessagesRenderOnce(t *testing.T) { + model := New(nil) + message := protocol.Message{ID: "setup-1", Text: "Replace the rejected key", Level: "warning"} + model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{SetupMode: true, Messages: []protocol.Message{message}})) + model.handleEnvelope(stateEnvelope(t, 2, protocol.Snapshot{SetupMode: true, Messages: []protocol.Message{message}})) + if len(model.setupLog) != 1 || !strings.Contains(ansi.Strip(model.setupLog[0]), message.Text) { + t.Fatalf("setup message was not rendered exactly once: %#v", model.setupLog) + } +} + +func TestMouseActivatesQuitPromptButtons(t *testing.T) { + model := New(nil) + model.width, model.height = 100, 30 + model.modal = modalQuit + view := model.modalView() + left, top, _, _ := model.centeredViewBounds(view) + + buttonPosition := func(label string) (int, int) { + t.Helper() + for row, line := range strings.Split(view, "\n") { + plain := ansi.Strip(line) + if index := strings.Index(plain, label); index >= 0 { + return left + ansi.StringWidth(plain[:index]), top + row + } + } + t.Fatalf("button %q not found", label) + return 0, 0 + } + + x, y := buttonPosition("No") + updated, cmd := model.updateMouse(tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + result := updated.(Model) + if result.modal != modalNone || result.quitting || cmd != nil { + t.Fatalf("No did not dismiss quit prompt: modal=%v quitting=%v cmd=%v", result.modal, result.quitting, cmd) + } + + model.modal = modalQuit + view = model.modalView() + left, top, _, _ = model.centeredViewBounds(view) + x, y = buttonPosition("Yes") + updated, cmd = model.updateMouse(tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + result = updated.(Model) + if !result.quitting || cmd == nil { + t.Fatalf("Yes did not confirm quit: quitting=%v cmd=%v", result.quitting, cmd) + } +} + +func TestModalKeepsBackgroundVisible(t *testing.T) { + model := New(nil) + model.width, model.height = 130, 30 + model.showSplash = false + model.ready = true + model.snapshot = protocol.Snapshot{Agents: []protocol.Agent{{ID: "one", Name: "UNIQUE_AGENT", Status: "running"}}} + model.resizeViewport() + model.modal = modalHelp + view := model.View() + if !strings.Contains(view, "UNIQUE_AGENT") { + t.Fatalf("modal overlay hid the background agent tree") + } + if !strings.Contains(view, "Strix Help") { + t.Fatalf("modal content missing") + } +} + +func TestChatWrapsWithinChatWidth(t *testing.T) { + model := New(nil) + model.width, model.height = 130, 30 + model.showSplash = false + model.ready = true + long := strings.Repeat("word ", 200) + model.snapshot = protocol.Snapshot{ + Agents: []protocol.Agent{{ID: "one", Name: "Agent", Status: "running"}}, + Events: []protocol.Event{{ID: "1", AgentID: "one", Type: "chat", Data: map[string]any{"role": "assistant", "content": long}}}, + } + model.resizeViewport() + for _, line := range strings.Split(model.chatContent(), "\n") { + if lipgloss.Width(line) > model.viewport.Width { + t.Fatalf("chat line width %d exceeds viewport width %d", lipgloss.Width(line), model.viewport.Width) + } + } +} + +func TestSnapshotRendersSelectedAgentEventsOnly(t *testing.T) { + model := New(nil) + model.width, model.height = 100, 30 + model.showSplash = false + model.ready = true + model.snapshot = protocol.Snapshot{ + SetupMode: false, + Agents: []protocol.Agent{{ID: "one", Name: "Agent One", Status: "running"}, {ID: "two", Name: "Agent Two", Status: "waiting"}}, + Events: []protocol.Event{ + {ID: "1", AgentID: "one", Type: "chat", Data: map[string]any{"role": "assistant", "content": "first"}}, + {ID: "2", AgentID: "two", Type: "chat", Data: map[string]any{"role": "assistant", "content": "second"}}, + }, + } + model.refreshViewport() + view := model.View() + if !strings.Contains(view, "first") || strings.Contains(view, "second") { + t.Fatalf("incorrect selected-agent events: %s", view) + } +} + +func TestVulnerabilityDetailScrollsWithoutHidingFooter(t *testing.T) { + model := New(nil) + model.width, model.height = 130, 40 + model.snapshot.Vulnerabilities = []map[string]any{{ + "title": "Long finding", + "severity": "high", + "description": strings.Repeat("detail line\n", 100), + }} + model.openModal(modalVulnerability) + + view := model.modalView() + if !strings.Contains(view, "Done") { + t.Fatalf("finding footer is not visible before scrolling: %s", view) + } + _, wantHeight := model.vulnerabilityDialogSize() + if got := len(strings.Split(view, "\n")); got != wantHeight { + t.Fatalf("finding dialog height = %d, want %d", got, wantHeight) + } + + updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyPgDown}) + model = updated.(Model) + if model.modal != modalVulnerability || model.vulnViewport.YOffset == 0 { + t.Fatalf("page down dismissed or did not scroll finding: modal=%v offset=%d", model.modal, model.vulnViewport.YOffset) + } + if view = model.modalView(); !strings.Contains(view, "Done") { + t.Fatalf("finding footer disappeared after scrolling: %s", view) + } + + before := model.vulnViewport.YOffset + modalLeft, modalTop, _, _ := model.centeredViewBounds(model.modalView()) + updated, _ = model.updateModalMouse(tea.MouseMsg{X: modalLeft + 4, Y: modalTop + 3, Button: tea.MouseButtonWheelDown}) + model = updated.(Model) + if model.vulnViewport.YOffset <= before { + t.Fatalf("mouse wheel did not scroll finding: before=%d after=%d", before, model.vulnViewport.YOffset) + } + updated, _ = model.updateModal(tea.KeyMsg{Type: tea.KeyEsc}) + if updated.(Model).modal != modalNone { + t.Fatal("escape did not close finding detail") + } +} + +func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) { + originalWriteClipboard := writeClipboard + t.Cleanup(func() { writeClipboard = originalWriteClipboard }) + var copied []string + writeClipboard = func(value string) error { + copied = append(copied, value) + return nil + } + + newModel := func() Model { + model := New(nil) + model.width, model.height = 130, 40 + model.snapshot.Vulnerabilities = []map[string]any{{ + "title": "Copy me", "severity": "high", "description": "Finding detail", + }} + model.openModal(modalVulnerability) + return model + } + + model := newModel() + updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyLeft}) + model = updated.(Model) + updated, cmd := model.updateModal(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(Model) + if cmd == nil || model.modal != modalVulnerability { + t.Fatalf("keyboard Copy did not keep the detail open: modal=%v cmd=%v", model.modal, cmd) + } + updated, _ = model.Update(cmd()) + model = updated.(Model) + if len(copied) != 1 || !strings.Contains(copied[0], "Copy me") || !strings.Contains(copied[0], "Finding detail") { + t.Fatalf("keyboard Copy wrote unexpected report: %#v", copied) + } + if !model.vulnerabilityCopied || !strings.Contains(ansi.Strip(model.modalView()), "Copied!") { + t.Fatal("successful keyboard Copy was not reflected in the dialog") + } + + model = newModel() + view := model.modalView() + left, top, _, _ := model.centeredViewBounds(view) + copyX, copyY := -1, -1 + for row, line := range strings.Split(view, "\n") { + plain := ansi.Strip(line) + if index := strings.Index(plain, "Copy"); index >= 0 { + copyX, copyY = left+ansi.StringWidth(plain[:index]), top+row + } + } + if copyX < 0 { + t.Fatal("Copy button was not rendered") + } + updated, cmd = model.updateModalMouse(tea.MouseMsg{ + X: copyX, Y: copyY, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, + }) + model = updated.(Model) + if cmd == nil || model.modalChoice != 0 { + t.Fatalf("mouse Copy was not activated: choice=%d cmd=%v", model.modalChoice, cmd) + } + cmd() + if len(copied) != 2 { + t.Fatalf("mouse Copy calls = %d, want 2", len(copied)) + } +} + +func TestVulnerabilitySelectionStaysVisible(t *testing.T) { + model := New(nil) + model.width, model.height = 130, 30 + model.focus = focusVulnerabilities + for i := 0; i < 20; i++ { + model.snapshot.Vulnerabilities = append(model.snapshot.Vulnerabilities, map[string]any{ + "title": fmt.Sprintf("Finding %02d", i), + }) + } + for range 15 { + updated, _ := model.updateMain(tea.KeyMsg{Type: tea.KeyDown}) + model = updated.(Model) + } + + view := ansi.Strip(model.vulnerabilitiesView(30, 10)) + if !strings.Contains(view, "Finding 15") || strings.Contains(view, "Finding 00") { + t.Fatalf("selected finding was not kept in the visible window: %s", view) + } +} + +func TestVulnerabilityListSupportsWheelAndPageNavigation(t *testing.T) { + model := New(nil) + model.width, model.height = 130, 30 + for i := 0; i < 20; i++ { + model.snapshot.Vulnerabilities = append(model.snapshot.Vulnerabilities, map[string]any{ + "title": fmt.Sprintf("Finding %02d", i), + }) + } + _, _, chatWidth, _ := model.layout() + _, _, agentHeight := model.sidebarHeights() + pageItems := model.vulnerabilityPageItems() + + updated, _ := model.updateMouse(tea.MouseMsg{ + X: chatWidth + 2, Y: model.viewerHeight() + agentHeight + 1, Button: tea.MouseButtonWheelDown, + }) + model = updated.(Model) + if model.focus != focusVulnerabilities || model.vulnOffset != 3 || model.selectedVuln != 3 { + t.Fatalf("wheel scroll did not focus and advance list: focus=%v offset=%d selected=%d", model.focus, model.vulnOffset, model.selectedVuln) + } + + updated, _ = model.updateMain(tea.KeyMsg{Type: tea.KeyPgDown}) + model = updated.(Model) + if model.selectedVuln != 3+pageItems { + t.Fatalf("page down selected %d", model.selectedVuln) + } + updated, _ = model.updateMain(tea.KeyMsg{Type: tea.KeyEnd}) + model = updated.(Model) + if model.selectedVuln != 19 { + t.Fatalf("end selected %d, want 19", model.selectedVuln) + } + view := ansi.Strip(model.vulnerabilitiesView(30, model.vulnerabilityPageSize())) + if !strings.Contains(view, "Finding 19") || strings.Contains(view, "Finding 00") { + t.Fatalf("end did not scroll the final finding into view: %s", view) + } +} + +func TestAgentTreeWheelScrollSurvivesSnapshot(t *testing.T) { + model := New(nil) + model.width, model.height = 130, 30 + model.ready = true + for i := 0; i < 40; i++ { + model.snapshot.Agents = append(model.snapshot.Agents, protocol.Agent{ + ID: fmt.Sprintf("agent-%02d", i), Name: fmt.Sprintf("Agent %02d", i), Status: "running", + }) + } + _, _, chatWidth, _ := model.layout() + + updated, _ := model.updateMouse(tea.MouseMsg{ + X: chatWidth + 2, Y: model.viewerHeight() + 2, Button: tea.MouseButtonWheelDown, + }) + model = updated.(Model) + if model.focus != focusAgents || model.agentOffset != 3 || model.selectedAgent != 3 { + t.Fatalf("wheel scroll did not advance tree: focus=%v offset=%d selected=%d", model.focus, model.agentOffset, model.selectedAgent) + } + + model.handleEnvelope(stateEnvelope(t, 1, model.snapshot)) + if model.agentOffset != 3 || model.selectedAgent != 3 { + t.Fatalf("snapshot reset manual tree scroll: offset=%d selected=%d", model.agentOffset, model.selectedAgent) + } +} + +func TestVulnerabilityDetailFitsNarrowTerminal(t *testing.T) { + model := New(nil) + model.width, model.height = 32, 15 + model.snapshot.Vulnerabilities = []map[string]any{{ + "title": "Narrow finding", + "description": strings.Repeat("long detail ", 50), + }} + model.openModal(modalVulnerability) + view := model.modalView() + + if !strings.Contains(view, "Done") { + t.Fatalf("finding footer is missing in narrow terminal: %s", view) + } + if got := len(strings.Split(view, "\n")); got > model.height { + t.Fatalf("finding dialog height %d exceeds terminal height %d", got, model.height) + } + for _, line := range strings.Split(view, "\n") { + if got := lipgloss.Width(line); got > model.width { + t.Fatalf("finding dialog width %d exceeds terminal width %d", got, model.width) + } + } +} + +func TestAgentTreeUsesDepthFirstOrderAndStableSelectionPosition(t *testing.T) { + parentRoot := "root" + parentA := "a" + model := New(nil) + model.snapshot.Agents = []protocol.Agent{ + {ID: "root", Name: "Root", Status: "running"}, + {ID: "a", ParentID: &parentRoot, Name: "Agent A", Status: "running"}, + {ID: "b", ParentID: &parentRoot, Name: "Agent B", Status: "running"}, + {ID: "a-child", ParentID: &parentA, Name: "Agent A Child", Status: "running"}, + } + + entries := agentTreeEntries(model.snapshot.Agents, nil) + var order []string + for _, entry := range entries { + order = append(order, model.snapshot.Agents[entry.index].ID) + } + if got, want := strings.Join(order, ","), "root,a,a-child,b"; got != want { + t.Fatalf("agent tree order = %q, want %q", got, want) + } + if view := ansi.Strip(model.agentsView(50, 10)); !strings.Contains(view, "β–Ό") { + t.Fatalf("expanded agent does not show its toggle: %s", view) + } + // A leaf carries no toggle at all, so its icon sits where a parent's + // toggle would be. + for _, line := range strings.Split(ansi.Strip(model.agentsView(50, 10)), "\n") { + if strings.Contains(line, "Agent B") && !strings.HasSuffix(line, "└─ βšͺ Agent B") { + t.Fatalf("leaf row reserved toggle space: %q", line) + } + } + + model.selectedAgent = 1 + selectedView := ansi.Strip(model.agentsView(50, 10)) + model.selectedAgent = 2 + unselectedView := ansi.Strip(model.agentsView(50, 10)) + lineFor := func(view, label string) string { + t.Helper() + for _, line := range strings.Split(view, "\n") { + if strings.Contains(line, label) { + return line + } + } + t.Fatalf("agent row %q not found in %s", label, view) + return "" + } + if selected, unselected := strings.Index(lineFor(selectedView, "Agent A"), "Agent A"), strings.Index(lineFor(unselectedView, "Agent A"), "Agent A"); selected != unselected { + t.Fatalf("selection moved agent label from column %d to %d", unselected, selected) + } + + // The cursor is a filled block behind the label; no row carries a gutter + // accent, which would indent every node past the panel padding. + if strings.ContainsAny(selectedView, "┃") { + t.Fatalf("agent rows drew a gutter accent: %s", selectedView) + } + + model.focus = focusAgents + model.selectedAgent = 1 + updated, _ := model.updateMain(tea.KeyMsg{Type: tea.KeyDown}) + result := updated.(Model) + if got := result.snapshot.Agents[result.selectedAgent].ID; got != "a-child" { + t.Fatalf("down selected %q, want depth-first child", got) + } + + updated, _ = model.updateMain(tea.KeyMsg{Type: tea.KeyEnter}) + result = updated.(Model) + collapsed := agentTreeEntries(result.snapshot.Agents, result.collapsedAgents) + for _, entry := range collapsed { + if result.snapshot.Agents[entry.index].ID == "a-child" { + t.Fatal("collapsed parent still rendered its child") + } + } + if view := ansi.Strip(result.agentsView(50, 10)); !strings.Contains(view, "β–Ά") { + t.Fatalf("collapsed agent does not show its toggle: %s", view) + } +} + +func TestAgentClickUsesRenderedWindowAfterResize(t *testing.T) { + model := New(nil) + model.width, model.height = 130, 20 + for i := range 20 { + model.snapshot.Agents = append(model.snapshot.Agents, protocol.Agent{ + ID: fmt.Sprintf("agent-%02d", i), Name: fmt.Sprintf("Agent %02d", i), Status: "running", + }) + } + model.selectedAgent = 15 + model.ensureAgentVisible() + if model.agentOffset == 0 { + t.Fatal("test setup did not scroll the agent tree") + } + + model.height = 60 + model.ensureAgentVisible() + _, _, chatWidth, _ := model.layout() + updated, _ := model.updateMouse(tea.MouseMsg{X: chatWidth + 1, Y: model.viewerHeight() + 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress}) + result := updated.(Model) + if result.selectedAgent != 0 { + t.Fatalf("click selected snapshot index %d instead of first rendered agent", result.selectedAgent) + } +} + +func TestFindingTitlesWrapAndViewerCTAIsClickable(t *testing.T) { + model := New(nil) + model.width, model.height = 130, 30 + model.snapshot.Vulnerabilities = []map[string]any{{ + "title": "A finding title that is intentionally long enough to wrap onto multiple lines", + }} + + view := ansi.Strip(model.vulnerabilitiesView(model.vulnerabilityListWidth(), 10)) + if strings.Count(view, "\n") < 1 || strings.Contains(view, "…") { + t.Fatalf("finding title was not wrapped: %s", view) + } + if cta := ansi.Strip(model.viewerView(40)); !strings.Contains(cta, "Watch live in browser") { + t.Fatalf("viewer CTA is missing: %s", cta) + } + + _, _, chatWidth, _ := model.layout() + _, cmd := model.updateMouse(tea.MouseMsg{ + X: chatWidth + 2, Y: 1, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, + }) + if cmd == nil { + t.Fatal("clicking viewer CTA did not send viewer.open") + } +} + +func TestRunningViewerShowsCompleteWrappedURL(t *testing.T) { + model := New(nil) + model.width, model.height = 130, 30 + url := "http://127.0.0.1:43123/?token=abcdefghijklmnopqrstuvwxyz0123456789" + model.snapshot.ViewerStatus = "running" + model.snapshot.ViewerURL = &url + + view := ansi.Strip(model.viewerView(18)) + if !strings.Contains(view, "Viewer running") { + t.Fatalf("viewer status is missing: %s", view) + } + urlLines := strings.Split(strings.SplitN(view, "\n", 2)[1], "\n") + for i := range urlLines { + urlLines[i] = strings.TrimRight(urlLines[i], " ") + } + if got := strings.Join(urlLines, ""); got != url { + t.Fatalf("wrapped viewer URL = %q, want %q", got, url) + } + if want := strings.Count(model.viewerView(model.viewerContentWidth()), "\n") + 3; model.viewerHeight() != want { + t.Fatalf("viewer height = %d, want %d", model.viewerHeight(), want) + } +} + +func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) { + top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbAgents)), "\n") + bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbAgents)), "\n") + + // The track is blank, so only the thumb is drawn. + if top[0] != "β–ˆ" || top[5] != " " { + t.Fatalf("top scrollbar is incorrect: %#v", top) + } + if bottom[0] != " " || bottom[5] != "β–ˆ" { + t.Fatalf("bottom scrollbar is incorrect: %#v", bottom) + } + if full := verticalScrollbar(4, 4, 4, 0, thumbAgents); full != "" { + t.Fatalf("non-overflowing scrollbar should be hidden: %q", full) + } + withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbAgents)) + if strings.ContainsAny(withoutBar, "β–ˆ") { + t.Fatalf("non-overflowing panel rendered a scrollbar: %q", withoutBar) + } +} + +// The bar takes exactly one column, so a scrolling panel keeps the rest. +func TestVerticalScrollbarOccupiesOneColumn(t *testing.T) { + rows := strings.Split(withVerticalScrollbar("content", 12, 2, 24, 2, 0, thumbTrace), "\n") + for _, row := range rows { + if width := ansi.StringWidth(row); width != 12 { + t.Fatalf("scrolling panel row width = %d, want 12", width) + } + } + if !strings.Contains(ansi.Strip(rows[0]), "β–ˆ") { + t.Fatalf("thumb missing from the first row: %q", rows[0]) + } +} + +func TestPanelPaddingResetsLeakingLineBackground(t *testing.T) { + leaky := "\x1b[48;2;82;82;82mstyled" + body := fixedPanelBody(leaky, 12, 1) + want := "styled\x1b[0m" + blackBG + if !strings.Contains(body, want) { + t.Fatalf("panel padding did not reset the source background: %q", body) + } + if width := ansi.StringWidth(body); width != 12 { + t.Fatalf("fixed panel body width = %d, want 12", width) + } +} + +func TestMainTraceTreeAndFindingsRenderScrollbars(t *testing.T) { + model := New(nil) + model.width, model.height = 150, 35 + model.ready = true + for i := 0; i < 40; i++ { + model.snapshot.Agents = append(model.snapshot.Agents, protocol.Agent{ + ID: fmt.Sprintf("agent-%02d", i), Name: fmt.Sprintf("Agent %02d", i), Status: "running", + }) + } + for i := 0; i < 20; i++ { + model.snapshot.Vulnerabilities = append(model.snapshot.Vulnerabilities, map[string]any{ + "title": fmt.Sprintf("Finding %02d", i), + }) + } + model.resizeViewport() + model.viewportContent = strings.Repeat("trace line\n", 100) + model.viewport.SetContent(model.viewportContent) + model.viewport.SetYOffset(10) + + view := ansi.Strip(model.mainView()) + if count := strings.Count(view, "β–ˆ"); count < 3 { + t.Fatalf("expected scroll thumbs in trace, tree, and findings; found %d\n%s", count, view) + } +} + +func TestMainScrollbarsSupportClickAndDrag(t *testing.T) { + model := New(nil) + model.width, model.height = 150, 35 + model.ready = true + for i := 0; i < 40; i++ { + model.snapshot.Agents = append(model.snapshot.Agents, protocol.Agent{ + ID: fmt.Sprintf("agent-%02d", i), Name: fmt.Sprintf("Agent %02d", i), Status: "running", + }) + } + for i := 0; i < 20; i++ { + model.snapshot.Vulnerabilities = append(model.snapshot.Vulnerabilities, map[string]any{ + "title": fmt.Sprintf("Finding %02d", i), + }) + } + model.resizeViewport() + model.viewportContent = strings.Repeat("trace line\n", 100) + model.viewport.SetContent(model.viewportContent) + showSidebar, _, chatWidth, chatHeight := model.layout() + viewerHeight := model.viewerHeight() + _, vulnHeight, agentHeight := model.sidebarHeights() + if !showSidebar { + t.Fatal("test requires sidebar") + } + + updated, _ := model.updateMouse(tea.MouseMsg{ + X: chatWidth - 2, Y: chatHeight - 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, + }) + model = updated.(Model) + if model.draggingScrollbar != scrollbarTrace || model.viewport.YOffset == 0 { + t.Fatalf("trace scrollbar click failed: drag=%v offset=%d", model.draggingScrollbar, model.viewport.YOffset) + } + updated, _ = model.updateMouse(tea.MouseMsg{X: chatWidth - 2, Y: 1, Action: tea.MouseActionMotion}) + model = updated.(Model) + if model.viewport.YOffset != 0 { + t.Fatalf("trace scrollbar drag did not reach top: %d", model.viewport.YOffset) + } + updated, _ = model.updateMouse(tea.MouseMsg{Action: tea.MouseActionRelease}) + model = updated.(Model) + if model.draggingScrollbar != scrollbarNone { + t.Fatal("trace scrollbar remained captured after release") + } + + updated, _ = model.updateMouse(tea.MouseMsg{ + X: model.width - 3, Y: viewerHeight + agentHeight - 3, + Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, + }) + model = updated.(Model) + if model.draggingScrollbar != scrollbarAgents || model.agentOffset == 0 { + t.Fatalf("agent scrollbar click failed: drag=%v offset=%d", model.draggingScrollbar, model.agentOffset) + } + updated, _ = model.updateMouse(tea.MouseMsg{Action: tea.MouseActionRelease}) + model = updated.(Model) + + updated, _ = model.updateMouse(tea.MouseMsg{ + X: model.width - 3, Y: viewerHeight + agentHeight + vulnHeight - 2, + Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, + }) + model = updated.(Model) + if model.draggingScrollbar != scrollbarFindings || model.vulnOffset == 0 { + t.Fatalf("findings scrollbar click failed: drag=%v offset=%d", model.draggingScrollbar, model.vulnOffset) + } +} + +func TestTerminalSnapshotWithoutAgentsDoesNotKeepLoading(t *testing.T) { + tests := []struct { + state string + error string + want string + }{ + {state: "failed", error: "authentication rejected", want: "Scan failed"}, + {state: "stopped", want: "Scan stopped"}, + {state: "completed", want: "Scan completed"}, + {state: "preparing", want: "Preparing scan..."}, + } + + for _, tt := range tests { + t.Run(tt.state, func(t *testing.T) { + model := New(nil) + model.viewport.Width, model.viewport.Height = 80, 20 + model.snapshot.ScanState = tt.state + if tt.error != "" { + model.snapshot.Error = &tt.error + } + + content := model.chatContent() + if !strings.Contains(content, tt.want) || strings.Contains(content, "Loading...") { + t.Fatalf("terminal state rendered incorrectly: %s", content) + } + if tt.error != "" && !strings.Contains(content, tt.error) { + t.Fatalf("failure detail was not rendered: %s", content) + } + }) + } +} + +func TestCrashedAndBudgetPausedAgentStatusParity(t *testing.T) { + model := New(nil) + model.width = 100 + model.snapshot.Agents = []protocol.Agent{ + {ID: "crashed", Name: "Crashed agent", Status: "crashed", ErrorMessage: "provider failed"}, + {ID: "paused", Name: "Paused agent", Status: "budget_paused"}, + } + + tree := ansi.Strip(model.agentsView(50, 10)) + if !strings.Contains(tree, "πŸ”΄ Crashed agent") || !strings.Contains(tree, "⏸ Paused agent") { + t.Fatalf("agent status icons do not match Textual: %s", tree) + } + crashed := ansi.Strip(model.statusView(100)) + if !strings.Contains(crashed, "provider failed") || !strings.Contains(crashed, "Send message to resume") { + t.Fatalf("crashed status lacks recovery guidance: %s", crashed) + } + model.selectedAgent = 1 + paused := ansi.Strip(model.statusView(100)) + if !strings.Contains(paused, "Budget limit reached") || !strings.Contains(paused, "Send a message to continue") || !strings.Contains(paused, "ctrl-q") { + t.Fatalf("budget-paused status lacks Textual guidance: %s", paused) + } +} + +func TestStopDialogAndCommandAreLimitedToActiveAgents(t *testing.T) { + tests := []struct { + status string + active bool + }{ + {status: "running", active: true}, + {status: "waiting", active: true}, + {status: "budget_paused", active: true}, + {status: "completed"}, + {status: "failed"}, + {status: "crashed"}, + {status: "stopped"}, + } + for _, tt := range tests { + t.Run(tt.status, func(t *testing.T) { + model := New(nil) + model.snapshot.Agents = []protocol.Agent{{ID: "agent", Name: "Agent", Status: tt.status}} + updated, _ := model.updateMain(tea.KeyMsg{Type: tea.KeyEsc}) + result := updated.(Model) + if got := result.modal == modalStop; got != tt.active { + t.Fatalf("stop dialog shown=%v, want %v", got, tt.active) + } + }) + } + + model, connection := newCommandTestModel(t) + model.snapshot.Agents = []protocol.Agent{{ID: "agent", Name: "Agent", Status: "running"}} + model.modal, model.modalChoice = modalStop, 0 + model.snapshot.Agents[0].Status = "completed" + updated, cmd := model.updateModal(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil || updated.(Model).modal != modalNone || connection.Len() != 0 { + t.Fatal("terminal status submitted a stale agent.stop command") + } +} + +func TestBudgetPauseShowsOneWarningToastUntilResumed(t *testing.T) { + model := New(nil) + model.snapshot.Agents = []protocol.Agent{{ID: "root", Name: "Strix", Status: "budget_paused"}} + if cmd := model.notifyBudgetPause(); cmd == nil { + t.Fatal("expected a toast command on first budget pause") + } + if !strings.Contains(model.toast, "Budget limit reached") { + t.Fatalf("toast %q missing budget warning", model.toast) + } + if cmd := model.notifyBudgetPause(); cmd != nil { + t.Fatal("budget toast should fire once per pause") + } + model.snapshot.Agents[0].Status = "running" + if cmd := model.notifyBudgetPause(); cmd != nil { + t.Fatal("no toast expected while running") + } + model.snapshot.Agents[0].Status = "budget_paused" + if cmd := model.notifyBudgetPause(); cmd == nil { + t.Fatal("expected the toast to re-arm after resuming") + } +} + +func TestStatsViewShowsSubscription(t *testing.T) { + model := New(nil) + model.snapshot.Model = "gpt-5" + model.snapshot.Subscription = true + model.snapshot.Usage = map[string]any{"total_tokens": float64(1200), "cost": 3.5} + stats := ansi.Strip(model.statsView()) + if !strings.Contains(stats, "ChatGPT subscription") { + t.Fatalf("stats missing subscription line: %q", stats) + } + if strings.Contains(stats, "$") { + t.Fatalf("subscription runs must not show a cost: %q", stats) + } +} + +func TestVulnerabilityMarkdownReport(t *testing.T) { + report := vulnerabilityMarkdownReport(map[string]any{ + "title": "SQLi in login", + "severity": "high", + "cvss": 8.1, + "description": "Injectable parameter.", + "poc_script_code": "```python\nprint('x')\n```", + "remediation_steps": "Use bound parameters.", + }) + for _, want := range []string{ + "# SQLi in login", "**Severity:** HIGH", "**CVSS:** 8.1", + "## Description", "```python\nprint('x')\n```", "## Remediation", + } { + if !strings.Contains(report, want) { + t.Fatalf("report missing %q:\n%s", want, report) + } + } +} + +func TestChatContentCachesBlocksUntilEventChanges(t *testing.T) { + model := New(nil) + model.width, model.height = 120, 30 + model.showSplash = false + model.ready = true + event := protocol.Event{ + ID: "1", AgentID: "one", Type: "tool", Version: 1, + Data: map[string]any{ + "tool_name": "exec_command", + "args": map[string]any{"cmd": "ls -la"}, + "result": "one\ntwo", + "status": "completed", + }, + } + model.snapshot = protocol.Snapshot{ + Agents: []protocol.Agent{{ID: "one", Name: "Agent", Status: "running"}}, + Events: []protocol.Event{event}, + } + model.resizeViewport() + first := model.chatContent() + if model.chatContent() != first { + t.Fatal("cached render changed without an event change") + } + + updated := event + updated.Version = 2 + updated.Data = map[string]any{ + "tool_name": "exec_command", + "args": map[string]any{"cmd": "whoami"}, + "result": "root", + "status": "completed", + } + model.snapshot.Events = []protocol.Event{updated} + next := model.chatContent() + if !strings.Contains(ansi.Strip(next), "whoami") { + t.Fatalf("new event version was served from cache: %q", ansi.Strip(next)) + } +} + +func TestChatContentRerendersOnWidthAndExpansionChange(t *testing.T) { + model := New(nil) + model.width, model.height = 120, 30 + model.showSplash = false + model.ready = true + model.snapshot = protocol.Snapshot{ + Agents: []protocol.Agent{{ID: "one", Name: "Agent", Status: "running"}}, + Events: []protocol.Event{{ + ID: "1", AgentID: "one", Type: "tool", Version: 1, + Data: map[string]any{ + "tool_name": "exec_command", + "args": map[string]any{"cmd": "seq 40"}, + "result": strings.Repeat("output line\n", 40), + "status": "completed", + }, + }}, + } + model.resizeViewport() + collapsed := model.chatContent() + model.expandedEvents["1"] = true + expanded := model.chatContent() + if strings.Count(expanded, "\n") <= strings.Count(collapsed, "\n") { + t.Fatal("expanding an event was served from cache") + } + model.width = 80 + model.resizeViewport() + narrow := model.chatContent() + for _, line := range strings.Split(narrow, "\n") { + if lipgloss.Width(line) > model.viewport.Width { + t.Fatalf("stale wrapped width after resize: %d > %d", lipgloss.Width(line), model.viewport.Width) + } + } +} diff --git a/strix/interface/tui/internal/app/selection.go b/strix/interface/tui/internal/app/selection.go new file mode 100644 index 00000000..8aa542e3 --- /dev/null +++ b/strix/interface/tui/internal/app/selection.go @@ -0,0 +1,284 @@ +package app + +// In-app text selection for the chat trace, in the tmux copy-mode style: +// drag with the left mouse button to highlight text, and the plain-text +// selection lands on the clipboard when the button is released. Coordinates +// are anchored to content lines, so an active selection survives scrolling. + +import ( + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +type selectionCopiedMsg struct{ err error } + +type toastExpiredMsg struct{ id int } + +// toastDuration matches the old Textual notify("Copied to clipboard", timeout=2). +const toastDuration = 2 * time.Second + +// showToast displays a transient notification and schedules its dismissal; +// the copy highlight is cleared together with the toast. +func (m *Model) showToast(text string) tea.Cmd { + return m.showToastFor(text, toastDuration) +} + +func (m *Model) showToastFor(text string, duration time.Duration) tea.Cmd { + m.toastID++ + m.toast = text + id := m.toastID + return tea.Tick(duration, func(time.Time) tea.Msg { return toastExpiredMsg{id: id} }) +} + +type selectionRegion int + +const ( + regionChat selectionRegion = iota + regionInput +) + +type selectionState struct { + active bool + dragging bool + region selectionRegion + // Content-line coordinates: anchor is where the drag started, head is + // where the pointer currently is. + anchorLine, anchorCol int + headLine, headCol int +} + +// bounds returns the selection in reading order: (fromLine, fromCol) to +// (toLine, toCol), with toCol exclusive. +func (s selectionState) bounds() (fromLine, fromCol, toLine, toCol int) { + if s.anchorLine < s.headLine || (s.anchorLine == s.headLine && s.anchorCol <= s.headCol) { + return s.anchorLine, s.anchorCol, s.headLine, s.headCol + 1 + } + return s.headLine, s.headCol, s.anchorLine, s.anchorCol + 1 +} + +// styleSelected uses reverse video directly so the highlight renders on any +// terminal profile. +func styleSelected(text string) string { + return "\x1b[7m" + text + "\x1b[27m" +} + +// chatContentCell maps main-view screen coordinates to a content cell inside +// the chat trace, honoring the pane border and the scroll offset. +func (m Model) chatContentCell(x, y int) (line, col int, ok bool) { + _, _, chatWidth, chatHeight := m.layout() + traceHeight := chatHeight - 2 + if x < 1 || x > chatWidth-2 || y < 1 || y > traceHeight { + return 0, 0, false + } + return m.viewport.YOffset + y - 1, x - 1, true +} + +// inputPromptWidth is the composer prompt ("> " / " ") column width; input +// selection coordinates are relative to the text after it. +const inputPromptWidth = 2 + +// inputTop returns the screen row of the composer's top border in the main view. +func (m Model) inputTop() int { + _, _, _, chatHeight := m.layout() + statusH := 0 + if m.statusVisible() { + statusH = 1 + } + return chatHeight + statusH +} + +// inputContentCell maps main-view screen coordinates to a text cell inside +// the composer, honoring the border, padding, and prompt columns. +func (m Model) inputContentCell(x, y int) (line, col int, ok bool) { + _, _, chatWidth, _ := m.layout() + top := m.inputTop() + textLeft := 2 + inputPromptWidth // border + padding, then the prompt + if x < textLeft || x > chatWidth-2 || y <= top || y > top+m.input.Height() { + return 0, 0, false + } + return y - top - 1, x - textLeft, true +} + +func (m *Model) beginSelection(region selectionRegion, line, col int) { + m.selection = selectionState{ + active: true, dragging: true, region: region, + anchorLine: line, anchorCol: col, + headLine: line, headCol: col, + } + m.toast = "" +} + +func (m *Model) extendSelection(line, col int) { + m.selection.headLine = max(0, line) + m.selection.headCol = max(0, col) +} + +// finishSelection ends the drag and copies the highlighted text; a plain +// click (no movement) clears any previous highlight and, in the chat trace, +// toggles the clicked tool's collapsed state. +func (m *Model) finishSelection() tea.Cmd { + m.selection.dragging = false + if m.selection.anchorLine == m.selection.headLine && m.selection.anchorCol == m.selection.headCol { + region := m.selection.region + line := m.selection.anchorLine + m.selection.active = false + if region == regionChat { + m.toggleEventAtLine(line) + } + return nil + } + text := m.selectedText() + if text == "" { + m.selection.active = false + return nil + } + if m.selection.region == regionChat { + if cleaned := cleanCopiedText(text); strings.TrimSpace(cleaned) != "" { + text = cleaned + } + } + return func() tea.Msg { + return selectionCopiedMsg{err: writeClipboard(text)} + } +} + +// iconPrefixes and decorativeLines port StrixTUIApp._ICON_PREFIXES and +// _DECORATIVE_LINES: UI ornaments dropped from copied chat text. +// kittyPlaceholderRune marks kitty graphics placeholder cells, which carry no +// copyable text. +const kittyPlaceholderRune = 0x10eeee + +var iconPrefixes = []string{ + "🐞 ", "🌐 ", "πŸ“‹ ", "🧠 ", "β—† ", "β—‡ ", "β—ˆ ", "β†’ ", "β—‹ ", "● ", "βœ“ ", "βœ— ", + "⚠ ", "▍ ", "▍", "┃ ", "β€’ ", ">_ ", " ", "<~> ", "[ ] ", "[~] ", "[β€’] ", +} + +var decorativeLines = map[string]bool{ + "● In progress...": true, + "βœ“ Done": true, + "βœ— Failed": true, + "βœ— Error": true, + "β—‹ Unknown": true, +} + +// cleanCopiedText ports _clean_copied_text: drop decorative status lines and +// horizontal rules, and strip leading UI icons while keeping indentation. +func cleanCopiedText(text string) string { + var cleaned []string + for _, line := range strings.Split(text, "\n") { + stripped := strings.TrimLeft(line, " \t") + if decorativeLines[stripped] { + continue + } + if stripped != "" && strings.Trim(stripped, "─") == "" { + continue + } + if strings.ContainsRune(stripped, kittyPlaceholderRune) { + continue + } + out := line + for _, prefix := range iconPrefixes { + if strings.HasPrefix(stripped, prefix) { + leading := line[:len(line)-len(stripped)] + out = leading + stripped[len(prefix):] + break + } + } + cleaned = append(cleaned, out) + } + return strings.Join(cleaned, "\n") +} + +// toggleEventAtLine expands or collapses the tool event rendered at the given +// chat content line. +func (m *Model) toggleEventAtLine(line int) { + for _, span := range m.eventSpans { + if line >= span.start && line <= span.end { + m.expandedEvents[span.eventID] = !m.expandedEvents[span.eventID] + m.refreshViewport() + return + } + } +} + +func (m Model) selectedText() string { + fromLine, fromCol, toLine, toCol := m.selection.bounds() + source := m.viewportContent + if m.selection.region == regionInput { + source = m.inputText() + } + lines := strings.Split(source, "\n") + var out []string + for i := max(0, fromLine); i <= min(toLine, len(lines)-1); i++ { + left, right := 0, ansi.StringWidth(lines[i]) + if i == fromLine { + left = fromCol + } + if i == toLine { + right = min(right, toCol) + } + out = append(out, strings.TrimRight(ansi.Strip(ansi.Cut(lines[i], left, right)), " ")) + } + return strings.TrimRight(strings.Join(out, "\n"), "\n") +} + +// inputText returns the composer's visible rows without the prompt columns, +// as the source for input-region selection. +func (m Model) inputText() string { + rows := strings.Split(m.input.View(), "\n") + for i, row := range rows { + rows[i] = ansi.Cut(row, inputPromptWidth, ansi.StringWidth(row)) + } + return strings.Join(rows, "\n") +} + +// highlightInputSelection re-styles the selected cells of the rendered +// composer, shifting columns past the prompt. +func (m Model) highlightInputSelection(view string) string { + if !m.selection.active || m.selection.region != regionInput { + return view + } + return highlightRows(view, 0, m.selection, inputPromptWidth) +} + +// highlightSelection re-styles the selected cells of the visible trace chunk. +// visible holds the rows starting at content line offset. +func (m Model) highlightSelection(visible string, offset int) string { + if !m.selection.active || m.selection.region != regionChat { + return visible + } + return highlightRows(visible, offset, m.selection, 0) +} + +// highlightRows applies reverse video to the selected cells; shift moves the +// selection columns right (for rows with a fixed prefix like the prompt). +func highlightRows(visible string, offset int, selection selectionState, shift int) string { + fromLine, fromCol, toLine, toCol := selection.bounds() + fromCol += shift + toCol += shift + rows := strings.Split(visible, "\n") + for i, row := range rows { + line := offset + i + if line < fromLine || line > toLine { + continue + } + width := ansi.StringWidth(row) + left, right := shift, width + if line == fromLine { + left = min(fromCol, width) + } + if line == toLine { + right = min(toCol, width) + } + if right <= left { + continue + } + rows[i] = ansi.Cut(row, 0, left) + + styleSelected(ansi.Strip(ansi.Cut(row, left, right))) + + ansi.Cut(row, right, width) + } + return strings.Join(rows, "\n") +} diff --git a/strix/interface/tui/internal/app/selection_test.go b/strix/interface/tui/internal/app/selection_test.go new file mode 100644 index 00000000..23f5160a --- /dev/null +++ b/strix/interface/tui/internal/app/selection_test.go @@ -0,0 +1,185 @@ +package app + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/usestrix/strix/tui/internal/protocol" +) + +func selectionModel(t *testing.T) Model { + t.Helper() + model := New(nil) + model.showSplash = false + model.ready = true + model.width, model.height = 130, 40 + model.snapshot.Agents = append( + model.snapshot.Agents, + protocol.Agent{ID: "root", Name: "Strix", Status: "running"}, + ) + model.resizeViewport() + model.viewportContent = strings.Join([]string{ + " first line of the trace", + " second line of the trace", + " third line of the trace", + }, "\n") + model.viewport.SetContent(model.viewportContent) + return model +} + +func TestDragSelectionCopiesPlainText(t *testing.T) { + model := selectionModel(t) + copied := "" + original := writeClipboard + writeClipboard = func(text string) error { + copied = text + return nil + } + defer func() { writeClipboard = original }() + + updated, _ := model.updateMouse(tea.MouseMsg{ + X: 2, Y: 1, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, + }) + model = updated.(Model) + if !model.selection.dragging { + t.Fatal("press in the trace did not start a selection") + } + updated, _ = model.updateMouse(tea.MouseMsg{X: 7, Y: 2, Action: tea.MouseActionMotion}) + model = updated.(Model) + updated, cmd := model.updateMouse(tea.MouseMsg{Action: tea.MouseActionRelease}) + model = updated.(Model) + if cmd == nil { + t.Fatal("selection release produced no copy command") + } + msg := cmd() + if copyMsg, ok := msg.(selectionCopiedMsg); !ok || copyMsg.err != nil { + t.Fatalf("unexpected copy result: %#v", msg) + } + want := "first line of the trace\n second" + if copied != want { + t.Fatalf("copied %q, want %q", copied, want) + } + if model.selection.dragging || !model.selection.active { + t.Fatalf("selection state after release: %+v", model.selection) + } + + updated, tick := model.Update(msg) + model = updated.(Model) + if model.toast != "Copied to clipboard" { + t.Fatalf("toast %q after copy", model.toast) + } + if !strings.Contains(model.View(), "Copied to clipboard") { + t.Fatal("toast is not rendered") + } + if tick == nil { + t.Fatal("toast was not scheduled to expire") + } + updated, _ = model.Update(toastExpiredMsg{id: model.toastID}) + model = updated.(Model) + if model.toast != "" || model.selection.active { + t.Fatalf("toast expiry left toast=%q selection=%+v", model.toast, model.selection) + } +} + +func TestPlainClickClearsSelectionWithoutCopying(t *testing.T) { + model := selectionModel(t) + original := writeClipboard + writeClipboard = func(string) error { + t.Fatal("plain click must not copy") + return nil + } + defer func() { writeClipboard = original }() + + updated, _ := model.updateMouse(tea.MouseMsg{ + X: 2, Y: 1, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, + }) + model = updated.(Model) + updated, cmd := model.updateMouse(tea.MouseMsg{Action: tea.MouseActionRelease}) + model = updated.(Model) + if cmd != nil { + t.Fatal("plain click produced a command") + } + if model.selection.active { + t.Fatal("plain click left an active selection") + } +} + +func TestHighlightSelectionRestylesSelectedCells(t *testing.T) { + model := selectionModel(t) + model.selection = selectionState{ + active: true, + anchorLine: 0, anchorCol: 1, + headLine: 0, headCol: 5, + } + + visible := model.highlightSelection(model.viewportContent, 0) + lines := strings.Split(visible, "\n") + if !strings.Contains(lines[0], "\x1b[") { + t.Fatalf("selected line was not restyled: %q", lines[0]) + } + if strings.Contains(lines[1], "\x1b[") || strings.Contains(lines[2], "\x1b[") { + t.Fatal("unselected lines were restyled") + } +} + +func TestSelectedTextSpansReversedDrag(t *testing.T) { + model := selectionModel(t) + model.selection = selectionState{ + active: true, + anchorLine: 2, anchorCol: 6, + headLine: 1, headCol: 1, + } + + want := "second line of the trace\n third" + if got := model.selectedText(); got != want { + t.Fatalf("selected text %q, want %q", got, want) + } +} + +func TestCleanCopiedTextStripsDecorations(t *testing.T) { + in := "βœ“ Done\n 🐞 SQL injection found\n────────\n>_ curl -s http://x\nplain line" + want := " SQL injection found\ncurl -s http://x\nplain line" + if got := cleanCopiedText(in); got != want { + t.Fatalf("cleaned %q, want %q", got, want) + } +} + +func TestClickTogglesToolExpansion(t *testing.T) { + model := New(nil) + model.showSplash = false + model.ready = true + model.width, model.height = 130, 40 + model.snapshot.Agents = []protocol.Agent{{ID: "root", Name: "Strix", Status: "running"}} + var output []string + for i := 0; i < 20; i++ { + output = append(output, "output line") + } + model.snapshot.Events = []protocol.Event{{ + ID: "ev-1", Type: "tool", AgentID: "root", Timestamp: "1", + Data: map[string]any{ + "tool_name": "exec_command", + "status": "completed", + "args": map[string]any{"cmd": "seq 20"}, + "result": strings.Join(output, "\n"), + }, + }} + model.resizeViewport() + + if !strings.Contains(model.viewportContent, "click to expand") { + t.Fatalf("long tool output should start collapsed:\n%s", model.viewportContent) + } + if len(model.eventSpans) != 1 || model.eventSpans[0].eventID != "ev-1" { + t.Fatalf("expected one expandable span, got %+v", model.eventSpans) + } + + model.toggleEventAtLine(model.eventSpans[0].start) + if !strings.Contains(model.viewportContent, "click to collapse") { + t.Fatalf("click should expand the tool:\n%s", model.viewportContent) + } + model.toggleEventAtLine(model.eventSpans[0].start) + if !strings.Contains(model.viewportContent, "click to expand") { + t.Fatal("second click should collapse again") + } +} diff --git a/strix/interface/tui/internal/app/setup.go b/strix/interface/tui/internal/app/setup.go new file mode 100644 index 00000000..8fb7362e --- /dev/null +++ b/strix/interface/tui/internal/app/setup.go @@ -0,0 +1,523 @@ +package app + +import ( + "fmt" + "net" + "regexp" + "strings" + "sync" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/usestrix/strix/tui/internal/render" +) + +func (m Model) submit(value string) (tea.Model, tea.Cmd) { + if m.snapshot.SetupMode { + return m.submitSetupPrompt(value) + } + if len(m.snapshot.Agents) == 0 { + m.errorText = "No agent is available" + return m, nil + } + if m.selectedAgent >= len(m.snapshot.Agents) { + m.selectedAgent = 0 + } + return m, send(m.client, "agent.send_message", map[string]any{"agent_id": m.snapshot.Agents[m.selectedAgent].ID, "message": value}) +} + +// submitSetupPrompt handles free text the way a coding agent's prompt does: +// anything that looks like a target is added, the rest becomes the scan +// instruction, and the prompt alone is enough to launch. With no target, the +// backend scans the current working directory. +func (m *Model) submitSetupPrompt(value string) (tea.Model, tea.Cmd) { + var commands []tea.Cmd + fields := strings.Fields(value) + targets := 0 + for _, field := range fields { + token := strings.Trim(field, ",;") + if !looksLikeTarget(token) || m.hasTarget(token) { + continue + } + targets++ + commands = append(commands, send(m.client, "setup.add_target", map[string]any{"target": token})) + } + if len(fields) > targets { + commands = append(commands, send(m.client, "setup.set_instruction", map[string]any{"instruction": value})) + } + // With a target, verify the model connection before the scan commits to it. + // A bare prompt launches optimistically, like a coding agent, and mounts the + // working directory - the backend asks about that from the live view, so the + // prompt is held here in case it is declined. + verify := targets > 0 || len(m.snapshot.Targets) > 0 + payload := map[string]any{"verify": verify} + if verify { + m.setupMsg("Verifying model connection...", render.Col(amber)) + } else { + m.pendingPrompt = value + payload["mount_working_dir"] = true + } + commands = append(commands, send(m.client, "setup.start", payload)) + // Ordered, not batched: setup.start leaves setup mode, so it must be the + // last command to reach the backend. Batched sends race, and once the + // preflight is skipped setup.start wins, making the target and instruction + // commands land after the guard closes and fail with a red error. + return *m, tea.Sequence(commands...) +} + +// answerMountConfirmation replies to the working-directory mount the backend is +// waiting on. Declining returns to the start screen, so the prompt goes back in +// the composer to be edited or given a target instead. +func (m *Model) answerMountConfirmation(approved bool) tea.Cmd { + if !approved && m.pendingPrompt != "" { + m.input.SetValue(m.pendingPrompt) + m.resizeViewport() + } + m.pendingPrompt = "" + return send(m.client, "setup.confirm_mount", map[string]any{"approved": approved}) +} + +func (m Model) hasTarget(candidate string) bool { + for _, target := range m.snapshot.Targets { + if target == candidate { + return true + } + } + return false +} + +// looksLikeTarget reports whether a whitespace-delimited token names something +// scannable: a URL, repo, filesystem path, domain, or IP address. +func looksLikeTarget(token string) bool { + if token == "" { + return false + } + if strings.Contains(token, "://") || strings.HasSuffix(token, ".git") { + return true + } + if strings.HasPrefix(token, "/") || strings.HasPrefix(token, "./") || strings.HasPrefix(token, "~/") || strings.HasPrefix(token, "../") { + return true + } + if ip := net.ParseIP(token); ip != nil { + return true + } + host := token + if at := strings.LastIndex(host, "@"); at >= 0 { + host = host[at+1:] + } + host = strings.SplitN(host, "/", 2)[0] + host = strings.SplitN(host, ":", 2)[0] + if !domainPattern.MatchString(host) { + return false + } + tld := host[strings.LastIndex(host, ".")+1:] + return len(tld) >= 2 && !isNumeric(tld) +} + +var domainPattern = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9]{2,}$`) + +func isNumeric(value string) bool { + for _, char := range value { + if char < '0' || char > '9' { + return false + } + } + return true +} + +// statusVisible mirrors #agent_status_display: shown only when an agent is +// selected during a scan; hidden (display:none) in setup mode. +func (m Model) statusVisible() bool { + return !m.snapshot.SetupMode && len(m.snapshot.Agents) > 0 +} + +func (m Model) layout() (showSidebar bool, sidebarWidth, chatWidth, chatHeight int) { + showSidebar = m.width >= 120 + if showSidebar { + sidebarWidth = max(24, m.width/5) + chatWidth = m.width - sidebarWidth - 1 + } else { + chatWidth = m.width + } + statusH := 0 + if m.statusVisible() { + statusH = 1 + } + chatHeight = max(4, m.height-statusH-(m.input.Height()+2)) + return +} + +// resizeViewport refits the composer and the scrollback to the terminal. The +// composer is sized width first: how far its content wraps, and so how tall it +// needs to be, depends on the width it is given. +func (m *Model) resizeViewport() { + if m.snapshot.SetupMode { + contentWidth := setupColumnWidth(m.width) + // The composer's border and padding each take a column per side. + m.input.SetWidth(max(3, contentWidth-4)) + // A clipped placeholder reads as an unfinished sentence, so a narrow + // composer gets the short prompt instead. + m.input.Placeholder = setupPlaceholder + if contentWidth-6 < lipgloss.Width(setupPlaceholder) { + m.input.Placeholder = setupPlaceholderShort + } + m.syncInputHeight() + m.viewport.Width = max(10, contentWidth) + m.viewport.Height = max(1, setupLogRows(m.setupLog)) + m.refreshViewport() + return + } + _, _, chatWidth, _ := m.layout() + // The accent bar and its padding each take a column. + m.input.SetWidth(max(3, chatWidth-3)) + m.syncInputHeight() + _, _, _, chatHeight := m.layout() + // Reserve two columns inside the border for the scrollbar gap and track. + m.viewport.Width = max(10, chatWidth-4) + m.viewport.Height = max(3, chatHeight-2) + m.refreshViewport() +} + +func (m *Model) refreshViewport() { + wasBottom := m.viewport.AtBottom() + content := m.setupContent() + if !m.snapshot.SetupMode { + content = m.chatContent() + } + m.viewportContent = content + m.viewport.SetContent(content) + if m.followOutput && wasBottom { + m.viewport.GotoBottom() + } +} + +func (m Model) setupContent() string { + var b strings.Builder + for _, line := range m.setupLog { + b.WriteString(line + "\n") + } + return strings.TrimSuffix(b.String(), "\n") +} + +// setupLogAppend records a chronological line in the setup scrollback. A line +// that is already there moves to the end instead of being repeated: retrying a +// launch that cannot succeed yet - no model configured, no target - would +// otherwise push the same pair of lines until they were all the log held. +func (m *Model) setupLogAppend(line string) { + for i, existing := range m.setupLog { + if existing == line { + m.setupLog = append(m.setupLog[:i], m.setupLog[i+1:]...) + break + } + } + m.setupLog = append(m.setupLog, line) +} + +// setupMsg appends a styled feedback line (success green, error red, notice dim). +func (m *Model) setupMsg(text string, style lipgloss.Style) { + m.setupLogAppend(style.Render(text)) +} + +// setupLogRows is how many feedback lines the launch column shows before the +// fit starts trimming them. It is a launch pad, not a scrollback. +func setupLogRows(log []string) int { return min(len(log), 6) } + +// Logo treatments, largest last. The launch column steps down through them as +// the terminal runs out of room. +const ( + logoNone = iota + logoCompact + logoFull +) + +// setupColumnWidth is the width of the centered launch column. It widens to +// the banner rather than lose it, as long as the terminal can still spare a +// margin either side. +func setupColumnWidth(terminal int) int { + width := min(72, max(24, terminal-8)) + if terminal >= wordmarkWidth()+2 { + width = max(width, wordmarkWidth()) + } + return width +} + +// setupFit records how much of the launch column survives at the current +// terminal size: the wordmark treatment, whether the tagline is shown, and how +// many feedback-log rows fit. +type setupFit struct { + width int + logo int + tagline bool + logRows int +} + +// setupFit picks the richest layout that still fits the terminal. Sections are +// surrendered in the order of shrink below - never the composer, which is the +// only thing on this screen the user has to reach. +func (m Model) setupFit() setupFit { + fit := setupFit{ + width: setupColumnWidth(m.width), + logo: logoFull, + tagline: true, + logRows: setupLogRows(m.setupLog), + } + if m.width < wordmarkWidth()+2 { + fit.logo = logoCompact + } + if m.height < 18 { + fit.logo, fit.tagline = min(fit.logo, logoCompact), false + } + shrink := []func(*setupFit) bool{ + func(f *setupFit) bool { return trimTo(&f.logRows, 3) }, + func(f *setupFit) bool { return clearFlag(&f.tagline) }, + func(f *setupFit) bool { return trimTo(&f.logRows, 0) }, + func(f *setupFit) bool { return trimTo(&f.logo, logoCompact) }, + func(f *setupFit) bool { return trimTo(&f.logo, logoNone) }, + } + for step := 0; step < len(shrink) && lipgloss.Height(m.setupBody(fit)) > m.height; { + if !shrink[step](&fit) { + step++ + } + } + return fit +} + +func trimTo(value *int, floor int) bool { + if *value <= floor { + return false + } + *value-- + return true +} + +func clearFlag(flag *bool) bool { + if !*flag { + return false + } + *flag = false + return true +} + +func (m Model) setupView() string { + fit := m.setupFit() + rows := strings.Split(m.setupBody(fit), "\n") + if len(rows) > m.height { + rows = rows[:max(0, m.height)] + } + // Anchor the column on its resting height rather than its current one, so a + // growing composer and new feedback both push downward. + // Centering on the live height walks the whole page up under the cursor, + // one row at a time, as the prompt wraps. + top := (m.height - m.setupRestingHeight(fit, len(rows))) / 2 + top = min(max(top, 0), max(0, m.height-len(rows))) + left := max(0, (m.width-fit.width)/2) + frame := make([]string, m.height) + for row := range frame { + line := "" + if index := row - top; index >= 0 && index < len(rows) { + line = strings.Repeat(" ", left) + rows[index] + } + frame[row] = padToWidth(line, m.width) + } + return strings.Join(frame, "\n") +} + +// setupRestingHeight is the column's height with the composer at its opening +// size and the transient sections closed: the layout the screen sits at when +// idle. Anchoring on this keeps the column still as the composer grows. +func (m Model) setupRestingHeight(fit setupFit, height int) int { + floor, _ := m.composerBounds() + height -= max(0, m.input.Height()-floor) + if fit.logRows > 0 && len(m.setupLog) > 0 { + height -= fit.logRows + 1 + } + return height +} + +// setupBody stacks the launch column: wordmark, composer with its scan summary, +// the target list, feedback and the key hints. Sections +// are separated by a blank line; the composer and its summary read as one unit. +func (m Model) setupBody(fit setupFit) string { + parts := make([]string, 0, 6) + if header := m.setupHeaderView(fit); header != "" { + parts = append(parts, header) + } + parts = append(parts, m.setupComposer(fit.width)) + if log := m.setupLogView(fit); log != "" { + parts = append(parts, log) + } + parts = append(parts, m.setupHintsView(fit.width)) + // Every row is padded to the column width: lipgloss.Place centers each line + // on its own, which would otherwise stagger the short rows. + rows := strings.Split(strings.Join(parts, "\n\n"), "\n") + for index, row := range rows { + rows[index] = padToWidth(row, fit.width) + } + return strings.Join(rows, "\n") +} + +// setupHeaderView centers the wordmark over the tagline. +func (m Model) setupHeaderView(fit setupFit) string { + center := lipgloss.NewStyle().Width(fit.width).Align(lipgloss.Center) + var rows []string + switch fit.logo { + case logoFull: + // The banner is tall enough to want air under it. + rows = append(rows, center.Render(wordmark())) + if fit.tagline { + rows = append(rows, "") + } + case logoCompact: + rows = append(rows, center.Render(lipgloss.NewStyle().Bold(true).Foreground(brightGreen).Render("STRIX"))) + } + if fit.tagline { + rows = append(rows, center.Render(render.Dim().Render("Open-source AI hackers for your apps"))) + } + return strings.Join(rows, "\n") +} + +// banner is the Strix wordmark: block letters with a bevelled edge. +const banner = ` β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•— + β–ˆβ–ˆβ•”β•β•β•β•β•β•šβ•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β• + β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ•”β• + β•šβ•β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β–ˆβ–ˆβ•— + β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β• β–ˆβ–ˆβ•— + β•šβ•β•β•β•β•β•β• β•šβ•β• β•šβ•β• β•šβ•β•β•šβ•β•β•šβ•β• β•šβ•β•` + +// wordmark renders the banner in solid brand green. Every row is padded out to +// the full block so centering cannot ripple the letterforms out of alignment. +var wordmarkOnce = sync.OnceValue(func() string { + green := lipgloss.NewStyle().Foreground(green) + lines := strings.Split(banner, "\n") + rows := make([]string, len(lines)) + for index, line := range lines { + rows[index] = green.Render(line + strings.Repeat(" ", wordmarkWidth()-lipgloss.Width(line))) + } + return strings.Join(rows, "\n") +}) + +func wordmark() string { return wordmarkOnce() } + +// wordmarkWidth is the cell width of the widest banner row. +var wordmarkWidth = sync.OnceValue(func() int { + block := 0 + for _, line := range strings.Split(banner, "\n") { + block = max(block, lipgloss.Width(line)) + } + return block +}) + +// setupComposer draws the prompt as a rounded panel that lights up green while +// it holds focus. The scan meta and targets live inside the panel, flush under +// the input, so everything shares one left edge - the way opencode aligns its +// home prompt. +func (m Model) setupComposer(width int) string { + border := dark + if m.focus == focusInput { + border = green + } + // Width covers the padding but not the border, so a box of the given total + // width sets width-2 here and hands the interior the width-4 that is left. + inner := max(1, width-4) + body := m.highlightInputSelection(m.input.View()) + body += "\n\n" + m.setupSummaryView(inner) + if targets := m.setupTargetsView(inner); targets != "" { + body += "\n" + targets + } + return lipgloss.NewStyle().Width(max(1, width-2)).Padding(0, 1). + Border(lipgloss.RoundedBorder()).BorderForeground(border). + Render(body) +} + +// setupSummaryView is the quiet meta line inside the panel: what the scan will +// run as, or what is still missing before it can run. +func (m Model) setupSummaryView(width int) string { + chips := []string{} + if model := strings.TrimSpace(m.snapshot.Model); model != "" { + name, provider := model, "" + if slash := strings.LastIndex(model, "/"); slash >= 0 { + provider, name = model[:slash], model[slash+1:] + } + chip := render.Col(green).Render("● ") + render.Col(white).Render(name) + if provider != "" { + chips = append(chips, chip, render.Dim().Render(provider)) + } else { + chips = append(chips, chip) + } + } else { + chips = append(chips, render.Col(amber).Render("β—‹ no model")+ + render.Dim().Render(" Β· set STRIX_LLM or configure one in your config")) + } + if m.snapshot.MaxBudgetUSD != nil { + chips = append(chips, render.Dim().Render(fmt.Sprintf("$%.2f budget", *m.snapshot.MaxBudgetUSD))) + } + return truncate(strings.Join(chips, render.Dim().Render(" Β· ")), max(1, width)) +} + +// setupTargetsView lists what the scan is pointed at, once anything is queued. +func (m Model) setupTargetsView(width int) string { + if len(m.snapshot.Targets) == 0 { + return "" + } + const visible = 4 + total := max(m.snapshot.TargetCount, len(m.snapshot.Targets)) + rows := []string{render.Bold(green).Render("Targets") + render.Dim().Render(fmt.Sprintf(" %d", total))} + for _, target := range m.snapshot.Targets[:min(visible, len(m.snapshot.Targets))] { + rows = append(rows, render.Col(dim).Render("β–Έ ")+render.Col(white).Render(truncate(target, max(1, width-2)))) + } + if hidden := total - visible; hidden > 0 { + rows = append(rows, render.Dim().Render(fmt.Sprintf("+%d more", hidden))) + } + return strings.Join(rows, "\n") +} + +// setupLogView shows the tail of the feedback log. The launch screen is a +// launch pad, not a scrollback, so only the most recent lines are kept. +func (m Model) setupLogView(fit setupFit) string { + if fit.logRows <= 0 || len(m.setupLog) == 0 { + return "" + } + tail := m.setupLog[max(0, len(m.setupLog)-fit.logRows):] + rows := make([]string, 0, len(tail)) + for _, line := range tail { + // Align with the panel interior [2, width-2]. + rows = append(rows, " "+truncate(line, max(1, fit.width-4))) + } + return strings.Join(rows, "\n") +} + +// setupHintsView is the closing key hint row, aligned to the panel's inner +// edges: keys flush under the input, the version at the far right. +func (m Model) setupHintsView(width int) string { + // The panel's interior spans [2, width-2]; match it so the row reads as a + // footer under the input rather than a stray line. + const pad = " " + inner := max(1, width-4) + key := lipgloss.NewStyle().Foreground(white).Render + label := render.Dim().Render + hint := func(k, text string) string { return key(k) + label(" "+text) } + left := hint("enter", "launch scan") + label(" ") + hint("shift+enter", "newline") + + label(" ") + hint("ctrl+c", "quit") + if lipgloss.Width(left) > inner { + left = hint("enter", "launch scan") + } + right := label("v" + appVersion) + gap := inner - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 2 { + return pad + left + } + return pad + left + strings.Repeat(" ", gap) + right +} + +// syncMountPrompt raises or clears the working-directory prompt to match the +// backend, which asks for it from the live view once a target-less scan is +// waiting on the answer. Following the snapshot rather than the keystroke keeps +// the prompt right across redraws and reconnects. +func (m *Model) syncMountPrompt() { + switch { + case m.snapshot.PendingMount != "" && m.modal != modalConfirmMount: + m.openModal(modalConfirmMount) + case m.snapshot.PendingMount == "" && m.modal == modalConfirmMount: + m.closeModal() + } +} diff --git a/strix/interface/tui/internal/app/setup_log_test.go b/strix/interface/tui/internal/app/setup_log_test.go new file mode 100644 index 00000000..b737bcb8 --- /dev/null +++ b/strix/interface/tui/internal/app/setup_log_test.go @@ -0,0 +1,95 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/usestrix/strix/tui/internal/protocol" + "github.com/usestrix/strix/tui/internal/render" +) + +// Retrying a launch that cannot succeed yet must not fill the log with copies of +// the same two lines. +func TestSetupLogCollapsesRepeatedAttempts(t *testing.T) { + m := New(nil) + m.snapshot.SetupMode = true + for range 4 { + m.setupMsg("Verifying model connection...", render.Col(amber)) + m.setupMsg("No model configured. Set STRIX_LLM first.", render.Col(red)) + } + if got := len(m.setupLog); got != 2 { + t.Fatalf("setup log holds %d lines after 4 identical attempts, want 2: %#v", got, m.setupLog) + } + // The newest line stays last so the log still reads chronologically. + if !strings.Contains(m.setupLog[1], "No model configured") { + t.Fatalf("most recent line is not last: %#v", m.setupLog) + } + m.setupMsg("\u2713 Added target: https://example.com", render.Col(green)) + if got := len(m.setupLog); got != 3 { + t.Fatalf("a distinct line did not append: %#v", m.setupLog) + } +} + +// The same collapse must hold for the path a real misconfiguration takes: a +// failing setup.start arriving as a command_result. +func TestRepeatedSetupStartFailureLogsOnce(t *testing.T) { + model, _ := newCommandTestModel(t) + model.snapshot.SetupMode = true + model.client.pending = map[string]string{} + model.client.pendingByKey = map[string]string{} + model.client.requestKeyByID = map[string]string{} + for i := range 3 { + requestID := "req-" + string(rune('a'+i)) + model.client.pending[requestID] = "setup.start" + model.client.pendingByKey["setup.start"] = requestID + model.client.requestKeyByID[requestID] = "setup.start" + payload, err := json.Marshal(protocol.CommandResult{ + OK: false, + Command: "setup.start", + Error: &protocol.CommandError{ + Code: "invalid_state", + Message: "No model configured. Set STRIX_LLM first.", + }, + }) + if err != nil { + t.Fatal(err) + } + model.handleEnvelope(protocol.Envelope{ + Version: protocol.Version, Type: "command_result", RequestID: requestID, Payload: payload, + }) + } + if got := len(model.setupLog); got != 1 { + t.Fatalf("three identical launch failures logged %d lines, want 1: %#v", got, model.setupLog) + } +} + +// Every Tab-reachable panel shows focus with the same green border. +func TestFocusedPanelsCarryTheGreenBorder(t *testing.T) { + // The profile is global; restore it so later tests still render unstyled. + previous := lipgloss.ColorProfile() + t.Cleanup(func() { lipgloss.SetColorProfile(previous) }) + lipgloss.SetColorProfile(termenv.TrueColor) + borderColorsOf := func(focus focusMode) string { + m := New(nil) + m.width, m.height = 130, 30 + m.showSplash = false + m.snapshot.ScanState = "running" + m.snapshot.Agents = []protocol.Agent{{ID: "a0", Name: "Strix", Status: "running"}} + m.snapshot.Vulnerabilities = []map[string]any{{"title": "XSS", "severity": "high"}} + m.focus = focus + m.resizeViewport() + return m.sidebarView(26, m.height) + } + idle := borderColorsOf(focusInput) + if strings.Contains(idle, "34;197;94") { + t.Fatal("an unfocused sidebar panel drew a green border") + } + for _, focus := range []focusMode{focusAgents, focusVulnerabilities} { + if !strings.Contains(borderColorsOf(focus), "34;197;94") { + t.Fatalf("focus %v did not draw a green border", focus) + } + } +} diff --git a/strix/interface/tui/internal/app/setup_prompt_test.go b/strix/interface/tui/internal/app/setup_prompt_test.go new file mode 100644 index 00000000..a18f8234 --- /dev/null +++ b/strix/interface/tui/internal/app/setup_prompt_test.go @@ -0,0 +1,292 @@ +package app + +import ( + "encoding/binary" + "encoding/json" + "reflect" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/usestrix/strix/tui/internal/protocol" +) + +// lastIndex returns the index of the last command of the given type, or -1. +func lastIndex(types []string, want string) int { + last := -1 + for i, value := range types { + if value == want { + last = i + } + } + return last +} + +// firstIndex returns the index of the first command of the given type, or -1. +func firstIndex(types []string, want string) int { + for i, value := range types { + if value == want { + return i + } + } + return -1 +} + +// drainCommands runs a (possibly batched) command and decodes every protocol +// frame the sends wrote to the connection, in order. +func drainCommands(t *testing.T, cmd tea.Cmd, connection *recordingConn) []protocol.Envelope { + t.Helper() + if cmd == nil { + return nil + } + var run func(tea.Cmd) + run = func(c tea.Cmd) { + if c == nil { + return + } + msg := c() + switch typed := msg.(type) { + case tea.BatchMsg: + for _, sub := range typed { + run(sub) + } + case sentMsg: + if typed.err != nil { + t.Fatalf("command failed: %#v", typed) + } + default: + // tea.Sequence yields an unexported sequenceMsg ([]tea.Cmd); run its + // commands in order, which is the ordering the sequence guarantees. + if value := reflect.ValueOf(msg); value.Kind() == reflect.Slice { + for i := 0; i < value.Len(); i++ { + if sub, ok := value.Index(i).Interface().(tea.Cmd); ok { + run(sub) + } + } + } + } + } + run(cmd) + + var envelopes []protocol.Envelope + raw := connection.Bytes() + for len(raw) >= 4 { + size := int(binary.BigEndian.Uint32(raw[:4])) + if len(raw) < size+4 { + t.Fatalf("truncated command frame") + } + var envelope protocol.Envelope + if err := json.Unmarshal(raw[4:size+4], &envelope); err != nil { + t.Fatal(err) + } + envelopes = append(envelopes, envelope) + raw = raw[size+4:] + } + return envelopes +} + +func commandTypes(envelopes []protocol.Envelope) []string { + types := make([]string, len(envelopes)) + for i, envelope := range envelopes { + types[i] = envelope.Type + } + return types +} + +// startVerify returns the verify flag on the setup.start command, and whether +// a setup.start command was present at all. +func startVerify(t *testing.T, envelopes []protocol.Envelope) (verify, found bool) { + t.Helper() + for _, envelope := range envelopes { + if envelope.Type != "setup.start" { + continue + } + var payload struct { + Verify bool `json:"verify"` + } + if err := json.Unmarshal(envelope.Payload, &payload); err != nil { + t.Fatal(err) + } + return payload.Verify, true + } + return false, false +} + +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +// startPayloadFlag reports a boolean field on the setup.start command. +func startPayloadFlag(t *testing.T, envelopes []protocol.Envelope, field string) (value, found bool) { + t.Helper() + for _, envelope := range envelopes { + if envelope.Type != "setup.start" { + continue + } + var payload map[string]any + if err := json.Unmarshal(envelope.Payload, &payload); err != nil { + t.Fatal(err) + } + flag, ok := payload[field].(bool) + return flag, ok + } + return false, false +} + +// A bare prompt launches straight away, asking to mount the working directory +// rather than adding it as a target. The prompt is held in case it is declined. +func TestSetupPromptWithoutTargetLaunchesAndRequestsMount(t *testing.T) { + connection := &recordingConn{} + model := New(&Client{conn: connection}) + model.snapshot = protocol.Snapshot{SetupMode: true, WorkingDir: "/Users/me/code/api"} + + updated, cmd := model.submit("find auth bugs in the login flow") + model = updated.(Model) + envelopes := drainCommands(t, cmd, connection) + types := commandTypes(envelopes) + + if !contains(types, "setup.set_instruction") || !contains(types, "setup.start") { + t.Fatalf("bare prompt did not launch: %v", types) + } + if contains(types, "setup.add_target") { + t.Fatalf("the working directory must not be added as a target: %v", types) + } + if mount, found := startPayloadFlag(t, envelopes, "mount_working_dir"); !found || !mount { + t.Fatalf("mount was not requested: mount_working_dir=%v found=%v", mount, found) + } + // A bare prompt launches optimistically: no model preflight. + if verify, found := startVerify(t, envelopes); !found || verify { + t.Fatalf("bare prompt should launch with verify=false, got verify=%v found=%v", verify, found) + } + // setup.start leaves setup mode, so it must be the last command sent. + if start, instr := firstIndex(types, "setup.start"), lastIndex(types, "setup.set_instruction"); start < instr { + t.Fatalf("setup.start (%d) must come after setup.set_instruction (%d): %v", start, instr, types) + } + if model.pendingPrompt != "find auth bugs in the login flow" { + t.Fatalf("prompt was not held in case the mount is declined: %q", model.pendingPrompt) + } + // The confirmation is not raised locally; the backend asks for it. + if model.modal != modalNone { + t.Fatalf("submit should not open a dialog itself: modal=%v", model.modal) + } +} + +// The backend asks from the live view, so the prompt follows the snapshot. +func TestPendingMountOpensAndClosesWithTheSnapshot(t *testing.T) { + model := New(nil) + model.width, model.height = 130, 40 + model.ready = true + + model.snapshot.PendingMount = "/Users/me/code/api" + model.syncMountPrompt() + if model.modal != modalConfirmMount { + t.Fatalf("pending mount did not raise the prompt: modal=%v", model.modal) + } + if model.modalChoice != 1 { + t.Fatalf("a consent prompt should default to declining, got %d", model.modalChoice) + } + // It names the directory the backend is waiting on, and stays compact. + view := ansi.Strip(model.mountConfirmView()) + if !strings.Contains(view, "/Users/me/code/api") { + t.Fatalf("prompt does not name the directory: %s", view) + } + if rows := strings.Count(view, "\n") + 1; rows > 6 { + t.Fatalf("corner prompt should stay compact, got %d rows:\n%s", rows, view) + } + + // Once the backend has the answer it clears, which closes the prompt. + model.snapshot.PendingMount = "" + model.syncMountPrompt() + if model.modal != modalNone { + t.Fatalf("prompt stayed open after the pending mount cleared: %v", model.modal) + } +} + +// Answering replies to the backend; declining puts the prompt back to edit. +func TestMountConfirmationAnswers(t *testing.T) { + for _, tc := range []struct { + name string + key tea.KeyMsg + choice int + approved bool + }{ + {"confirm", tea.KeyMsg{Type: tea.KeyEnter}, 0, true}, + {"cancel", tea.KeyMsg{Type: tea.KeyEnter}, 1, false}, + {"escape", tea.KeyMsg{Type: tea.KeyEsc}, 1, false}, + } { + connection := &recordingConn{} + model := New(&Client{conn: connection}) + model.width, model.height = 130, 40 + model.snapshot = protocol.Snapshot{SetupMode: true, WorkingDir: "/Users/me/code/api"} + updated, _ := model.submit("find auth bugs in the login flow") + model = updated.(Model) + connection.Reset() + model.snapshot.PendingMount = "/Users/me/code/api" + model.syncMountPrompt() + model.modalChoice = tc.choice + + updated, cmd := model.updateModal(tc.key) + model = updated.(Model) + envelopes := drainCommands(t, cmd, connection) + + if len(envelopes) != 1 || envelopes[0].Type != "setup.confirm_mount" { + t.Fatalf("%s: expected one setup.confirm_mount, got %v", tc.name, commandTypes(envelopes)) + } + var payload struct { + Approved bool `json:"approved"` + } + if err := json.Unmarshal(envelopes[0].Payload, &payload); err != nil { + t.Fatal(err) + } + if payload.Approved != tc.approved { + t.Fatalf("%s: approved=%v, want %v", tc.name, payload.Approved, tc.approved) + } + // Declining returns to the start screen, so the prompt comes back. + want := "" + if !tc.approved { + want = "find auth bugs in the login flow" + } + if got := model.input.Value(); got != want { + t.Fatalf("%s: composer = %q, want %q", tc.name, got, want) + } + if model.pendingPrompt != "" { + t.Fatalf("%s: held prompt was not cleared: %q", tc.name, model.pendingPrompt) + } + } +} + +// A prompt that names a target adds it and launches. +func TestSetupPromptWithTargetLaunches(t *testing.T) { + connection := &recordingConn{} + model := New(&Client{conn: connection}) + model.snapshot = protocol.Snapshot{SetupMode: true} + + _, cmd := model.submit("https://juice-shop.example.com hit the coupon endpoint") + envelopes := drainCommands(t, cmd, connection) + types := commandTypes(envelopes) + + for _, want := range []string{"setup.add_target", "setup.set_instruction", "setup.start"} { + if !contains(types, want) { + t.Fatalf("missing %s in %v", want, types) + } + } + // A named target keeps the upfront model check. + if verify, found := startVerify(t, envelopes); !found || !verify { + t.Fatalf("targeted prompt should launch with verify=true, got verify=%v found=%v", verify, found) + } + // The target and instruction must reach the backend before setup.start + // closes the setup guard. + start := firstIndex(types, "setup.start") + if target := lastIndex(types, "setup.add_target"); start < target { + t.Fatalf("setup.start (%d) must come after setup.add_target (%d): %v", start, target, types) + } + if instr := lastIndex(types, "setup.set_instruction"); start < instr { + t.Fatalf("setup.start (%d) must come after setup.set_instruction (%d): %v", start, instr, types) + } +} diff --git a/strix/interface/tui/internal/app/update.go b/strix/interface/tui/internal/app/update.go new file mode 100644 index 00000000..0cccea5d --- /dev/null +++ b/strix/interface/tui/internal/app/update.go @@ -0,0 +1,600 @@ +package app + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { + switch key.String() { + case "f1": + m.openModal(modalHelp) + return m, nil + case "ctrl+c", "ctrl+q": + // Nothing to lose on the start screen; quit without confirmation. + if m.snapshot.SetupMode { + m.quitting = true + return m, tea.Batch(send(m.client, "app.quit", map[string]any{}), tea.Quit) + } + m.modalChoice = 1 + m.openModal(modalQuit) + return m, nil + case "ctrl+o": + return m, send(m.client, "viewer.open", map[string]any{}) + case "tab": + m.cycleFocus(1) + return m, nil + case "shift+tab": + m.cycleFocus(-1) + return m, nil + case "esc": + if !m.snapshot.SetupMode && m.selectedAgentCanStop() { + m.modalChoice = 1 + m.openModal(modalStop) + } + return m, nil + case "up", "down": + if m.focus == focusAgents && len(m.snapshot.Agents) > 0 { + delta := 1 + if key.String() == "up" { + delta = -1 + } + entries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents) + row := selectedAgentRow(entries, m.selectedAgent) + row = max(0, min(len(entries)-1, row+delta)) + m.selectedAgent = entries[row].index + m.ensureAgentVisible() + m.refreshViewport() + return m, nil + } + if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 { + delta := 1 + if key.String() == "up" { + delta = -1 + } + m.moveVulnerabilitySelection(delta) + m.ensureVulnerabilityVisible() + return m, nil + } + case "enter", " ": + if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 { + if key.String() == "enter" { + m.openModal(modalVulnerability) + return m, nil + } + } + if m.focus == focusAgents { + if m.selectedAgent < len(m.snapshot.Agents) { + agentID := m.snapshot.Agents[m.selectedAgent].ID + if hasAgentChildren(agentID, m.snapshot.Agents) { + if m.collapsedAgents == nil { + m.collapsedAgents = map[string]bool{} + } + m.collapsedAgents[agentID] = !m.collapsedAgents[agentID] + m.ensureAgentVisible() + } + } + return m, nil + } + if key.String() == "enter" && m.focus == focusInput { + value := strings.TrimSpace(m.input.Value()) + m.input.SetValue("") + m.resizeViewport() + if value != "" { + return m.submit(value) + } + return m, nil + } + case "pgup": + if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 { + m.moveVulnerabilitySelection(-m.vulnerabilityPageItems()) + m.ensureVulnerabilityVisible() + return m, nil + } + m.focus = focusChat + m.input.Blur() + m.followOutput = false + m.viewport.HalfViewUp() + return m, nil + case "pgdown": + if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 { + m.moveVulnerabilitySelection(m.vulnerabilityPageItems()) + m.ensureVulnerabilityVisible() + return m, nil + } + m.focus = focusChat + m.input.Blur() + m.viewport.HalfViewDown() + return m, nil + case "home": + if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 { + m.selectedVuln = 0 + m.ensureVulnerabilityVisible() + return m, nil + } + case "end": + if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 { + m.selectedVuln = len(m.snapshot.Vulnerabilities) - 1 + m.ensureVulnerabilityVisible() + return m, nil + } + m.viewport.GotoBottom() + m.followOutput = true + return m, nil + } + if m.focus == focusChat { + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(key) + return m, cmd + } + var cmd tea.Cmd + m.input, cmd = m.input.Update(key) + // Typing changes how far the composer wraps, so refit it. + m.resizeViewport() + return m, cmd +} + +// updateMouse routes wheel and click events to the pane under the pointer. +func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.modal != modalNone { + return m.updateModalMouse(msg) + } + if m.snapshot.SetupMode { + return m.updateSetupMouse(msg) + } + showSidebar, _, chatWidth, chatHeight := m.layout() + viewerHeight := m.viewerHeight() + _, vulnHeight, agentHeight := m.sidebarHeights() + x, y := msg.X, msg.Y + if m.updateMainScrollbarMouse( + msg, showSidebar, chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight, + ) { + return m, nil + } + if m.selection.dragging { + switch msg.Action { + case tea.MouseActionMotion: + // Clamp to the owning pane so dragging past an edge keeps + // extending the selection. + if m.selection.region == regionInput { + top := m.inputTop() + cx := min(max(x, 2+inputPromptWidth), max(2+inputPromptWidth, chatWidth-2)) + cy := min(max(y, top+1), top+m.input.Height()) + if line, col, ok := m.inputContentCell(cx, cy); ok { + m.extendSelection(line, col) + } + return m, nil + } + traceHeight := chatHeight - 2 + cx := min(max(x, 1), max(1, chatWidth-2)) + cy := min(max(y, 1), max(1, traceHeight)) + if line, col, ok := m.chatContentCell(cx, cy); ok { + m.extendSelection(line, col) + } + return m, nil + case tea.MouseActionRelease: + return m, m.finishSelection() + } + } + switch msg.Button { + case tea.MouseButtonWheelUp: + if showSidebar && x >= chatWidth+1 { + switch { + case y < viewerHeight: + return m, nil + case y < viewerHeight+agentHeight: + m.focus = focusAgents + m.input.Blur() + m.agentOffset = max(0, m.agentOffset-3) + m.keepAgentSelectionInWindow() + m.refreshViewport() + case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight: + m.focus = focusVulnerabilities + m.input.Blur() + m.vulnOffset = max(0, m.vulnOffset-3) + m.keepVulnerabilitySelectionInWindow() + } + return m, nil + } + m.focus = focusChat + m.input.Blur() + m.followOutput = false + m.viewport.LineUp(3) + return m, nil + case tea.MouseButtonWheelDown: + if showSidebar && x >= chatWidth+1 { + switch { + case y < viewerHeight: + return m, nil + case y < viewerHeight+agentHeight: + m.focus = focusAgents + m.input.Blur() + rows := m.agentPageSize() + m.agentOffset = min(max(0, len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents))-rows), m.agentOffset+3) + m.keepAgentSelectionInWindow() + m.refreshViewport() + case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight: + m.focus = focusVulnerabilities + m.input.Blur() + m.vulnOffset = min(max(0, len(m.snapshot.Vulnerabilities)-1), m.vulnOffset+3) + m.keepVulnerabilitySelectionInWindow() + } + return m, nil + } + m.viewport.LineDown(3) + if m.viewport.AtBottom() { + m.followOutput = true + } + return m, nil + } + if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft { + return m, nil + } + statusH := 0 + if m.statusVisible() { + statusH = 1 + } + inputTop := chatHeight + statusH + // Chat column: chat box on top, input box below the (optional) status row. + if x < chatWidth { + switch { + case y >= inputTop: + m.focus = focusInput + m.input.Focus() + if line, col, ok := m.inputContentCell(x, y); ok { + m.beginSelection(regionInput, line, col) + } else { + m.selection.active = false + } + case y < chatHeight: + m.focus = focusChat + m.input.Blur() + if line, col, ok := m.chatContentCell(x, y); ok { + m.beginSelection(regionChat, line, col) + } else { + m.selection.active = false + } + default: + m.selection.active = false + } + return m, nil + } + + if !showSidebar || x < chatWidth+1 { + return m, nil + } + // Sidebar: viewer, agents, vulnerabilities, then stats. + switch { + case y < viewerHeight: + return m, send(m.client, "viewer.open", map[string]any{}) + case y < viewerHeight+agentHeight: + m.focus = focusAgents + m.input.Blur() + // Content starts after the top border (1) and vertical padding (1). + entries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents) + start := windowStart(m.agentOffset, len(entries), max(1, agentHeight-4)) + localY := y - viewerHeight + if row := start + localY - 2; localY >= 2 && localY < agentHeight-2 && row < len(entries) { + m.selectedAgent = entries[row].index + agentID := m.snapshot.Agents[m.selectedAgent].ID + if hasAgentChildren(agentID, m.snapshot.Agents) { + m.collapsedAgents[agentID] = !m.collapsedAgents[agentID] + m.ensureAgentVisible() + } + m.refreshViewport() + } + case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight: + m.focus = focusVulnerabilities + m.input.Blur() + // Content starts after the top border (1); clicking a row opens its detail. + row := y - viewerHeight - agentHeight - 1 + if idx := m.vulnerabilityIndexAtRow(row); row >= 0 && row < vulnHeight-2 && idx >= 0 { + m.selectedVuln = idx + m.openModal(modalVulnerability) + } + } + return m, nil +} + +func (m *Model) updateMainScrollbarMouse( + msg tea.MouseMsg, + showSidebar bool, + chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight int, +) bool { + if msg.Action == tea.MouseActionRelease { + if m.draggingScrollbar == scrollbarNone { + return false + } + m.draggingScrollbar = scrollbarNone + return true + } + if msg.Action == tea.MouseActionMotion && m.draggingScrollbar != scrollbarNone { + m.scrollFromMouse(m.draggingScrollbar, msg.Y, chatHeight, viewerHeight, agentHeight) + return true + } + if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft { + return false + } + + target := scrollbarNone + switch { + case msg.X == chatWidth-2 && msg.Y >= 1 && msg.Y < chatHeight-1 && + m.viewport.TotalLineCount() > m.viewport.VisibleLineCount(): + target = scrollbarTrace + case showSidebar && msg.X == m.width-3 && msg.Y >= viewerHeight+2 && + msg.Y < viewerHeight+agentHeight-2 && + len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize(): + target = scrollbarAgents + case showSidebar && vulnHeight > 0 && msg.X == m.width-3 && + msg.Y >= viewerHeight+agentHeight+1 && + msg.Y < viewerHeight+agentHeight+vulnHeight-1: + totalRows, _ := m.vulnerabilityScrollRows() + if totalRows > m.vulnerabilityPageSize() { + target = scrollbarFindings + } + } + if target == scrollbarNone { + return false + } + m.draggingScrollbar = target + m.scrollFromMouse(target, msg.Y, chatHeight, viewerHeight, agentHeight) + return true +} + +func (m *Model) scrollFromMouse( + target scrollbarTarget, + y, chatHeight, viewerHeight, agentHeight int, +) { + switch target { + case scrollbarTrace: + height := max(1, chatHeight-2) + offset := scrollbarOffset(y-1, height, m.viewport.TotalLineCount(), m.viewport.VisibleLineCount()) + m.focus = focusChat + m.input.Blur() + m.viewport.SetYOffset(offset) + m.followOutput = m.viewport.AtBottom() + case scrollbarAgents: + height := m.agentPageSize() + total := len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) + m.focus = focusAgents + m.input.Blur() + m.agentOffset = scrollbarOffset(y-viewerHeight-2, height, total, height) + m.keepAgentSelectionInWindow() + m.refreshViewport() + case scrollbarFindings: + height := m.vulnerabilityPageSize() + totalRows, _ := m.vulnerabilityScrollRows() + rowOffset := scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height) + m.focus = focusVulnerabilities + m.input.Blur() + m.vulnOffset = m.vulnerabilityOffsetAtRow(rowOffset) + m.keepVulnerabilitySelectionInWindow() + } +} + +func scrollbarOffset(row, height, total, visible int) int { + maxOffset := max(0, total-visible) + if height <= 1 || maxOffset == 0 { + return 0 + } + return maxOffset * min(max(0, row), height-1) / (height - 1) +} + +func (m Model) updateSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + switch msg.Button { + case tea.MouseButtonWheelUp: + m.focus = focusChat + m.input.Blur() + m.followOutput = false + m.viewport.LineUp(3) + return m, nil + case tea.MouseButtonWheelDown: + m.viewport.LineDown(3) + if m.viewport.AtBottom() { + m.followOutput = true + } + return m, nil + } + if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { + m.focus = focusInput + m.input.Focus() + } + return m, nil +} + +func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.modal == modalVulnerability { + view := m.modalView() + left, top, _, _ := m.centeredViewBounds(view) + viewportLeft := left + 4 // border and three-cell dialog padding + viewportTop := top + 3 // border and two-cell dialog padding + insideViewport := msg.X >= viewportLeft && msg.X < viewportLeft+m.vulnViewport.Width+2 && + msg.Y >= viewportTop && msg.Y < viewportTop+m.vulnViewport.Height + switch msg.Button { + case tea.MouseButtonWheelUp: + if insideViewport { + m.vulnViewport.LineUp(3) + } + return m, nil + case tea.MouseButtonWheelDown: + if insideViewport { + m.vulnViewport.LineDown(3) + } + return m, nil + } + } + if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft { + return m, nil + } + view := m.modalView() + switch m.modal { + case modalQuit, modalStop: + if m.centeredLabelHit(view, "Yes", msg.X, msg.Y) { + m.modalChoice = 0 + return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter}) + } + if m.centeredLabelHit(view, "No", msg.X, msg.Y) { + m.modalChoice = 1 + return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter}) + } + case modalVulnerability: + if m.centeredLabelHit(view, "Copy", msg.X, msg.Y) { + m.modalChoice = 0 + cmd := m.startVulnerabilityCopy() + return m, cmd + } + if m.centeredLabelHit(view, "Done", msg.X, msg.Y) { + m.modalChoice = 1 + m.closeModal() + } + } + return m, nil +} + +func (m Model) centeredViewBounds(view string) (left, top, width, height int) { + width = lipgloss.Width(view) + height = strings.Count(view, "\n") + 1 + left = max(0, (m.width-width)/2) + top = max(0, (m.height-height)/2) + return +} + +func (m Model) centeredLabelHit(view, label string, x, y int) bool { + left, top, _, _ := m.centeredViewBounds(view) + for row, line := range strings.Split(view, "\n") { + plain := ansi.Strip(line) + index := strings.Index(plain, label) + if index < 0 || y != top+row { + continue + } + start := left + ansi.StringWidth(plain[:index]) + return x >= start-1 && x < start+ansi.StringWidth(label)+1 + } + return false +} + +func (m *Model) cycleFocus(delta int) { + available := []focusMode{focusInput, focusChat} + if m.width >= 120 { + available = append(available, focusAgents) + if len(m.snapshot.Vulnerabilities) > 0 { + available = append(available, focusVulnerabilities) + } + } + idx := 0 + for i, focus := range available { + if focus == m.focus { + idx = i + } + } + m.focus = available[clampCycle(idx+delta, len(available))] + if m.focus == focusInput { + m.input.Focus() + } else { + m.input.Blur() + } +} + +func clampCycle(value, length int) int { + if length <= 0 { + return 0 + } + return (value%length + length) % length +} + +func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.modal == modalHelp { + if key.String() != "" { + m.closeModal() + } + return m, nil + } + if m.modal == modalVulnerability { + switch key.String() { + case "esc": + m.closeModal() + case "left", "right", "tab", "shift+tab": + m.modalChoice = 1 - m.modalChoice + case "enter": + if m.modalChoice == 0 { + cmd := m.startVulnerabilityCopy() + return m, cmd + } + m.closeModal() + case "c": + m.modalChoice = 0 + cmd := m.startVulnerabilityCopy() + return m, cmd + case "up": + m.vulnViewport.LineUp(1) + case "down": + m.vulnViewport.LineDown(1) + case "pgup": + m.vulnViewport.HalfViewUp() + case "pgdown": + m.vulnViewport.HalfViewDown() + case "home": + m.vulnViewport.GotoTop() + case "end": + m.vulnViewport.GotoBottom() + } + return m, nil + } + switch key.String() { + case "esc": + if m.modal == modalConfirmMount { + // The backend is waiting on an answer; escape declines it. + return m, m.answerMountConfirmation(false) + } + m.closeModal() + return m, nil + case "left", "right", "up", "down", "tab": + m.modalChoice = 1 - m.modalChoice + return m, nil + case "enter": + modal, choice := m.modal, m.modalChoice + if modal == modalConfirmMount { + // The snapshot closes this prompt once the backend has the answer. + return m, m.answerMountConfirmation(choice == 0) + } + m.closeModal() + if choice == 1 { + return m, nil + } + if modal == modalQuit { + m.quitting = true + return m, tea.Batch(send(m.client, "app.quit", map[string]any{}), tea.Quit) + } + if modal == modalStop && m.selectedAgentCanStop() { + agent := m.snapshot.Agents[m.selectedAgent] + return m, send(m.client, "agent.stop", map[string]any{"agent_id": agent.ID}) + } + } + return m, nil +} + +func (m *Model) openModal(mode modalMode) { + m.modal = mode + m.input.Blur() + if mode == modalConfirmMount { + // A consent prompt defaults to declining. + m.modalChoice = 1 + } + if mode == modalVulnerability { + m.modalChoice = 1 + m.vulnerabilityCopied = false + m.vulnerabilityCopyError = "" + m.resizeVulnerabilityViewport() + m.vulnViewport.GotoTop() + } +} + +func (m *Model) closeModal() { + m.modal = modalNone + if m.focus == focusInput { + m.input.Focus() + } +} diff --git a/strix/interface/tui/internal/app/view.go b/strix/interface/tui/internal/app/view.go new file mode 100644 index 00000000..f50f4a46 --- /dev/null +++ b/strix/interface/tui/internal/app/view.go @@ -0,0 +1,782 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/usestrix/strix/tui/internal/protocol" + "github.com/usestrix/strix/tui/internal/render" +) + +// eventSpan records which content lines of the chat trace belong to an +// expandable tool event, so clicks can toggle its collapsed state. +type eventSpan struct { + start, end int + eventID string +} + +// renderedBlock is a chat block kept across frames: rendering (syntax +// highlighting, image placements, wrapping) is expensive and only changes +// when the event, the chat width, or its expanded state changes. +type renderedBlock struct { + version int + width int + expanded bool + wrapped string + expandable bool + height int +} + +func (m *Model) renderEvent(event protocol.Event, width int) renderedBlock { + expanded := m.expandedEvents[event.ID] + if cached, ok := m.blockCache[event.ID]; ok && + cached.version == event.Version && cached.width == width && cached.expanded == expanded { + return cached + } + var block string + expandable := false + switch event.Type { + case "chat": + block = render.Chat(event.Data) + case "tool": + name := render.StringValue(event.Data["tool_name"]) + block, expandable = render.CollapseTool(render.Tool(event.Data), name, expanded) + } + entry := renderedBlock{version: event.Version, width: width, expanded: expanded, expandable: expandable} + if block != "" { + entry.wrapped = wrapBlock(block, width) + entry.height = strings.Count(entry.wrapped, "\n") + 1 + } + if m.blockCache == nil { + m.blockCache = map[string]renderedBlock{} + } + m.blockCache[event.ID] = entry + return entry +} + +func (m *Model) chatContent() string { + if len(m.snapshot.Agents) == 0 { + switch m.snapshot.ScanState { + case "failed": + message := "Scan failed" + if m.snapshot.Error != nil && strings.TrimSpace(*m.snapshot.Error) != "" { + detail := strings.ReplaceAll(strings.TrimSpace(*m.snapshot.Error), "\n", " ") + message += "\n\n" + ansi.Truncate(detail, max(1, m.viewport.Width-4), "...") + } + return centeredPlaceholder(message, m.viewport.Width, m.viewport.Height) + case "stopped": + return centeredPlaceholder("Scan stopped", m.viewport.Width, m.viewport.Height) + case "completed": + return centeredPlaceholder("Scan completed", m.viewport.Width, m.viewport.Height) + case "preparing": + return centeredPlaceholder("Preparing scan...", m.viewport.Width, m.viewport.Height) + default: + return centeredPlaceholder("Loading...", m.viewport.Width, m.viewport.Height) + } + } + if m.selectedAgent >= len(m.snapshot.Agents) { + return "" + } + agentID := m.snapshot.Agents[m.selectedAgent].ID + events := append([]protocol.Event(nil), m.snapshot.Events...) + // Match _gather_agent_events: sort by (timestamp, id). + sort.SliceStable(events, func(i, j int) bool { + if events[i].Timestamp != events[j].Timestamp { + return events[i].Timestamp < events[j].Timestamp + } + return events[i].ID < events[j].ID + }) + // .chat-content has padding: 0 1 β€” one column of horizontal padding, so wrap + // to width-2 and indent every line by one cell. + contentWidth := max(1, m.viewport.Width-2) + render.SetImageWidth(contentWidth - 2) + var blocks []string + var spans []eventSpan + line := 0 + for _, event := range events { + if event.AgentID != agentID { + continue + } + entry := m.renderEvent(event, contentWidth) + if entry.wrapped == "" { + continue + } + if len(blocks) > 0 { + line++ // blank separator line between blocks + } + if entry.expandable { + spans = append(spans, eventSpan{start: line, end: line + entry.height - 1, eventID: event.ID}) + } + line += entry.height + blocks = append(blocks, entry.wrapped) + } + m.eventSpans = spans + if len(blocks) == 0 { + return centeredPlaceholder("Starting agent...", m.viewport.Width, m.viewport.Height) + } + return indentLines(strings.Join(blocks, "\n\n"), " ") +} + +// indentLines prefixes every line with the given pad (chat-content padding-left). +func indentLines(s, pad string) string { + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = pad + line + } + return strings.Join(lines, "\n") +} + +func centeredPlaceholder(text string, width, height int) string { + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, lipgloss.NewStyle().Foreground(dim).Italic(true).Render(text)) +} + +// truncate clips to a display-cell width, honoring wide runes and ANSI styling. +func truncate(value string, limit int) string { + if limit <= 0 { + return "" + } + if ansi.StringWidth(value) <= limit { + return value + } + return ansi.Truncate(value, limit, "…") +} + +// wrapBlock hard-wraps each line of a rendered block to the given cell width so +// content never spills past the chat border, matching Textual's word wrapping. +func wrapBlock(value string, width int) string { + if width <= 0 { + return value + } + var out []string + for _, line := range strings.Split(value, "\n") { + if ansi.StringWidth(line) <= width { + out = append(out, line) + continue + } + out = append(out, strings.Split(ansi.Wrap(line, width, " -"), "\n")...) + } + return strings.Join(out, "\n") +} + +func verticalScrollbar(height, total, visible, offset int, thumb lipgloss.Color) string { + if height <= 0 || total <= visible { + return "" + } + visible = min(max(1, visible), max(1, total)) + total = max(visible, total) + thumbHeight := height + thumbStart := 0 + if total > visible { + thumbHeight = max(1, height*visible/total) + maxOffset := total - visible + thumbStart = (height - thumbHeight) * min(max(0, offset), maxOffset) / maxOffset + } + thumbStyle := lipgloss.NewStyle().Foreground(thumb) + bar := make([]string, height) + for row := range bar { + bar[row] = " " + if row >= thumbStart && row < thumbStart+thumbHeight { + bar[row] = thumbStyle.Render("β–ˆ") + } + } + return strings.Join(bar, "\n") +} + +// withVerticalScrollbar reserves a single column for the bar, and only while the +// panel actually overflows. +func withVerticalScrollbar( + content string, + width, height, total, visible, offset int, + thumb lipgloss.Color, +) string { + if total <= visible { + return fixedPanelBody(content, width, height) + } + body := fixedPanelBody(content, max(1, width-1), height) + bar := verticalScrollbar(height, total, visible, offset, thumb) + return lipgloss.JoinHorizontal(lipgloss.Top, body, bar) +} + +func visibleContent(content string, offset, height int) string { + if height <= 0 || content == "" { + return "" + } + lines := strings.Split(content, "\n") + start := min(max(0, offset), len(lines)) + end := min(len(lines), start+height) + return strings.Join(lines[start:end], "\n") +} + +func fixedPanelBody(content string, width, height int) string { + lines := strings.Split(content, "\n") + body := make([]string, max(0, height)) + for row := range body { + line := "" + if row < len(lines) { + line = ansi.Truncate(lines[row], max(1, width), "") + } + padding := strings.Repeat(" ", max(0, width-ansi.StringWidth(line))) + // End every source style before padding; otherwise inline-code and tool + // backgrounds can paint the empty space through to the panel border. + body[row] = line + "\x1b[0m" + blackBG + padding + } + return strings.Join(body, "\n") +} + +func (m Model) View() string { + view := fillBackground(m.viewInner()) + // Kitty graphics transmissions ride out of band: they carry no visible + // cells, so writing them directly keeps the Bubble Tea frame diff clean. + for _, seq := range render.DrainImageTransmissions() { + _, _ = os.Stdout.WriteString(seq) + } + return view +} + +func (m Model) viewInner() string { + if m.showSplash { + return m.splashView() + } + if !m.ready { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, lipgloss.NewStyle().Foreground(dim).Render("Connecting to Strix…"), lipgloss.WithWhitespaceBackground(black)) + } + main := m.mainView() + if m.snapshot.SetupMode { + main = m.setupView() + } + if m.modal == modalConfirmMount { + // A corner prompt, not a dialog: it sits out of the way in the live view + // while the scan waits on the answer. + main = m.cornerOverlay(main, m.modalView()) + } else if m.modal != modalNone { + // Only the vulnerability detail dims its backdrop (#000000 80%); Help, + // Quit and Stop are transparent. + main = m.overlay(main, m.modalView(), m.modal == modalVulnerability) + } + return m.toastOverlay(main) +} + +// cornerOverlay splices a panel in directly above the composer, right-aligned +// with it, leaving the rest of the view visible behind it. +func (m Model) cornerOverlay(view, panel string) string { + if panel == "" { + return view + } + fg := strings.Split(panel, "\n") + bg := strings.Split(view, "\n") + panelWidth := lipgloss.Width(panel) + // Right edge of the chat column, so it lines up with the composer rather + // than covering the sidebar. + _, _, chatWidth, _ := m.layout() + left := max(0, min(chatWidth, m.width)-panelWidth) + // Bottom row sits just above the composer, clearing the status line so the + // scan state and quit hint stay readable. + statusH := 0 + if m.statusVisible() { + statusH = 1 + } + top := max(0, m.inputTop()-statusH-len(fg)) + for row := top; row < min(len(bg), top+len(fg)); row++ { + fgLine := ansi.Truncate(fg[row-top], max(0, m.width-left), "") + rightStart := left + lipgloss.Width(fgLine) + leftPart := padToWidth(ansi.Truncate(bg[row], left, ""), left) + rightPart := "" + if lipgloss.Width(bg[row]) > rightStart { + rightPart = ansi.TruncateLeft(bg[row], rightStart, "") + } + bg[row] = leftPart + fgLine + rightPart + } + return strings.Join(bg, "\n") +} + +// toastOverlay splices a transient notification into the bottom-right corner, +// where Textual's notify() toasts appeared. +func (m Model) toastOverlay(view string) string { + if m.toast == "" { + return view + } + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(green). + Background(black). + Foreground(textColor). + Padding(0, 1). + Render(m.toast) + fg := strings.Split(box, "\n") + bg := strings.Split(view, "\n") + boxWidth := lipgloss.Width(box) + left := max(0, m.width-boxWidth-2) + top := max(0, m.height-len(fg)-1) + for row := top; row < min(len(bg), top+len(fg)); row++ { + fgLine := fg[row-top] + rightStart := left + boxWidth + leftPart := padToWidth(ansi.Truncate(bg[row], left, ""), left) + rightPart := "" + if lipgloss.Width(bg[row]) > rightStart { + rightPart = ansi.TruncateLeft(bg[row], rightStart, "") + } + bg[row] = leftPart + fgLine + rightPart + } + return strings.Join(bg, "\n") +} + +// blackBG is the SGR that selects a solid black background. +const blackBG = "\x1b[48;2;0;0;0m" + +// fillBackground paints the whole frame black like Textual's Screen background. +// Bubble Tea has no screen compositor, so any cell the view does not explicitly +// color shows the terminal's default background. lipgloss emits a full reset +// (\x1b[0m) at the end of every styled span, which also clears the background, so +// we reassert black after each reset (and at the start). Spans that set their own +// background β€” inline code, selected rows, buttons β€” keep it, because their color +// is emitted before the reset. +func fillBackground(view string) string { + if view == "" { + return view + } + return blackBG + strings.ReplaceAll(view, "\x1b[0m", "\x1b[0m"+blackBG) +} + +func (m Model) splashView() string { + shine := "Starting Strix Agent" + chars := []rune(shine) + pos := m.splashFrame % (len(chars) + 8) + var start strings.Builder + for i, char := range chars { + distance := i - pos + if distance < 0 { + distance = -distance + } + // Tiers match SplashScreen._build_start_line_text: + // bright_white / white / #a3a3a3 / #525252. + color := lipgloss.Color("#525252") + bold := false + switch { + case distance <= 1: + color, bold = brightWhite, true + case distance <= 3: + color, bold = white, true + case distance <= 5: + color = lipgloss.Color("#a3a3a3") + } + start.WriteString(lipgloss.NewStyle().Foreground(color).Bold(bold).Render(string(char))) + } + welcome := lipgloss.NewStyle().Bold(true).Foreground(white).Render("Welcome to ") + + lipgloss.NewStyle().Bold(true).Foreground(green).Render("Strix") + + lipgloss.NewStyle().Bold(true).Foreground(white).Render("!") + version := lipgloss.NewStyle().Foreground(white).Faint(true).Render("v" + appVersion) + tagline := lipgloss.NewStyle().Foreground(white).Faint(true).Render("Open-source AI hackers for your apps") + url := lipgloss.NewStyle().Bold(true).Foreground(green).Render("strix.ai") + // The wordmark is shared with the launch screen so the two read as one moment. + content := wordmark() + "\n\n" + + welcome + "\n" + version + "\n" + tagline + "\n\n" + + start.String() + "\n\n" + url + if warn := m.snapshot.ModelWarning; warn != "" { + content += "\n\n" + splashModelWarning(warn) + } + panel := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(green).Padding(1, 6).Align(lipgloss.Center).Render(content) + // #splash_screen background is solid black. + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, panel, + lipgloss.WithWhitespaceBackground(black)) +} + +// splashModelWarning ports SplashScreen._build_model_warning_text. +func splashModelWarning(model string) string { + yellow := lipgloss.Color("#eab308") + return lipgloss.NewStyle().Bold(true).Foreground(yellow).Render("⚠ ") + + lipgloss.NewStyle().Bold(true).Foreground(render.Cyan).Render(model) + + lipgloss.NewStyle().Foreground(yellow).Render(" is not a recommended frontier model - pentest quality could be degraded") +} + +// chatPaneKey identifies everything the bordered trace depends on. +type chatPaneKey struct { + offset int + width, height int + border lipgloss.Color + selection selectionState +} + +// chatPane memoizes the bordered trace: slicing, scrollbar padding and border +// styling all re-measure every visible cell, which is costly when inline image +// placeholders (a base rune plus two combining marks per cell) fill the pane, +// and the trace is unchanged across most frames. +var chatPane struct { + key chatPaneKey + content string + out string +} + +func (m Model) renderChatPane(width, height int, border lipgloss.Color) string { + key := chatPaneKey{offset: m.viewport.YOffset, width: width, height: height, border: border, selection: m.selection} + if chatPane.out != "" && chatPane.key == key && chatPane.content == m.viewportContent { + return chatPane.out + } + trace := withVerticalScrollbar( + m.highlightSelection(visibleContent(m.viewportContent, m.viewport.YOffset, height), m.viewport.YOffset), + width, + height, + m.viewport.TotalLineCount(), + m.viewport.VisibleLineCount(), + m.viewport.YOffset, + thumbTrace, + ) + out := lipgloss.NewStyle().Width(width).Height(height). + Border(lipgloss.RoundedBorder()).BorderForeground(border).Render(trace) + chatPane.key, chatPane.content, chatPane.out = key, m.viewportContent, out + return out +} + +func (m Model) mainView() string { + showSidebar, sidebarWidth, chatWidth, chatHeight := m.layout() + // Matches tui_styles.tcss: #chat_history border is near-black when idle and + // green on focus. + chatBorder := lipgloss.Color("#0a0a0a") + if m.focus == focusChat { + chatBorder = green + } + traceHeight := chatHeight - 2 + chat := m.renderChatPane(chatWidth-2, traceHeight, chatBorder) + + inputBorder := dark + if m.focus == focusInput { + inputBorder = green + } + input := lipgloss.NewStyle().Width(chatWidth - 2).Height(m.input.Height()). + Border(lipgloss.RoundedBorder()).BorderForeground(inputBorder).PaddingLeft(1). + Render(m.highlightInputSelection(m.input.View())) + + // Chat column: chat history, optional status row, then input β€” all chat-width. + leftParts := []string{chat} + if m.statusVisible() { + leftParts = append(leftParts, m.statusView(chatWidth)) + } + leftParts = append(leftParts, input) + leftColumn := strings.Join(leftParts, "\n") + + body := leftColumn + if showSidebar { + body = lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", m.sidebarView(sidebarWidth, m.height)) + } + return lipgloss.NewStyle().Background(black).Foreground(textColor).Render(body) +} + +// Every panel that Tab can reach shows focus the way the chat and the composer +// do, with a green border. The stylesheet asked for near-black on the tree +// instead, through a Tree:focus rule that lost to the #agents_tree id selector +// and so never applied - honoring it made the outline vanish on the one panel +// that had just become active. +func (m Model) sidebarView(width, height int) string { + // Stats box height fits its content (auto, max 15); vulns panel max-height 12. + statsBody := m.statsView() + statsHeight, vulnHeight, agentHeight := m.sidebarHeights() + agentBorder := dark + if m.focus == focusAgents { + agentBorder = green + } + // #agents_tree padding: 1 (all sides); interior lines = box - border - v.padding. + agentRows := max(1, agentHeight-4) + agentEntries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents) + agents := withVerticalScrollbar( + m.agentsView(max(1, width-5), agentRows), + width-4, + agentRows, + len(agentEntries), + agentRows, + m.agentOffset, + thumbAgents, + ) + parts := []string{ + lipgloss.NewStyle().Width(width-2).Height(m.viewerHeight()-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(m.viewerView(width - 4)), + lipgloss.NewStyle().Width(width-2).Height(agentHeight-2).Border(lipgloss.RoundedBorder()).BorderForeground(agentBorder).Padding(1, 1).Render(agents), + } + if vulnHeight > 0 { + vulnBorder := dark + if m.focus == focusVulnerabilities { + vulnBorder = green + } + vulnRows := max(1, vulnHeight-2) + totalRows, offsetRows := m.vulnerabilityScrollRows() + findings := withVerticalScrollbar( + m.vulnerabilitiesView(max(1, width-5), vulnRows), + width-4, + vulnRows, + totalRows, + vulnRows, + offsetRows, + thumbFindings, + ) + parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(vulnRows).Border(lipgloss.RoundedBorder()).BorderForeground(vulnBorder).Padding(0, 1).Render(findings)) + } + parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(statsHeight-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(statsBody)) + return strings.Join(parts, "\n") +} + +func (m Model) sidebarHeights() (statsHeight, vulnHeight, agentHeight int) { + // Measure the stats panel the way its box will render it: a long model name + // wraps inside the sidebar, and counting only its newlines would size the + // box short and push the whole frame past the bottom of the terminal. + statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.viewerContentWidth()).Render(m.statsView())) + statsHeight = min(15, statsRows+2) + if len(m.snapshot.Vulnerabilities) > 0 { + rows := 0 + width := m.vulnerabilityListWidth() + for i := range m.snapshot.Vulnerabilities { + rows += len(m.vulnerabilityTitleLines(i, width)) + } + vulnHeight = min(12, rows+2) + } + agentHeight = max(3, m.height-m.viewerHeight()-statsHeight-vulnHeight) + return +} + +func (m Model) viewerHeight() int { + return strings.Count(m.viewerView(m.viewerContentWidth()), "\n") + 3 +} + +func (m Model) viewerContentWidth() int { + _, sidebarWidth, _, _ := m.layout() + if sidebarWidth == 0 { + sidebarWidth = 24 + } + return max(1, sidebarWidth-4) +} + +func (m Model) viewerView(width int) string { + switch m.snapshot.ViewerStatus { + case "running": + status := lipgloss.NewStyle().Foreground(green).Render("● Viewer running") + if m.snapshot.ViewerURL != nil && strings.TrimSpace(*m.snapshot.ViewerURL) != "" { + url := wrapBlock(strings.TrimSpace(*m.snapshot.ViewerURL), width) + return status + "\n" + lipgloss.NewStyle().Foreground(dim).Render(url) + } + return status + case "unavailable": + return truncate(lipgloss.NewStyle().Foreground(amber).Render("Viewer UI not built"), width) + case "failed": + return truncate(lipgloss.NewStyle().Foreground(red).Render("Viewer failed to start"), width) + default: + return truncate(lipgloss.NewStyle().Foreground(textColor).Render("β–Ά Watch live in browser"), width) + } +} + +func (m Model) statsView() string { + w := lipgloss.NewStyle().Foreground(white) + var b strings.Builder + if model := m.snapshot.Model; model != "" { + b.WriteString(w.Render(model)) + } + if m.snapshot.Subscription { + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString(lipgloss.NewStyle().Foreground(green).Render("ChatGPT subscription")) + } + total := numberValue(m.snapshot.Usage["total_tokens"]) + if total > 0 { + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString(w.Render(fmt.Sprintf("%s tokens", formatCount(total)))) + if cost := floatValue(m.snapshot.Usage["cost"]); !m.snapshot.Subscription && cost > 0 { + b.WriteString(w.Render(fmt.Sprintf(" Β· $%.2f", cost))) + } + } + if caido := m.snapshot.CaidoURL; caido != "" { + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(white).Render("Caido: ") + w.Render(caido)) + } + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString(w.Render("v" + appVersion)) + return b.String() +} + +func numberValue(value any) int64 { + switch v := value.(type) { + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + case json.Number: + n, _ := v.Int64() + return n + } + return 0 +} +func floatValue(value any) float64 { + switch v := value.(type) { + case float64: + return v + case int: + return float64(v) + case string: + n, _ := strconv.ParseFloat(v, 64) + return n + } + return 0 +} +func formatCount(value int64) string { + if value >= 1_000_000 { + return fmt.Sprintf("%.1fM", float64(value)/1_000_000) + } + if value >= 1_000 { + return fmt.Sprintf("%.1fK", float64(value)/1_000) + } + return strconv.FormatInt(value, 10) +} + +func (m Model) statusView(width int) string { + // Status text color mirrors #status_text (#a3a3a3); keymap hints use white + // keys and dim actions (keymap_styled). See _get_status_display_content. + left, right := "", "" + if len(m.snapshot.Agents) > 0 && !m.snapshot.SetupMode { + agent := m.snapshot.Agents[m.selectedAgent] + quitHint := lipgloss.NewStyle().Foreground(white).Render("ctrl-q") + lipgloss.NewStyle().Foreground(dim).Render(" ") + lipgloss.NewStyle().Foreground(dim).Render("quit") + switch agent.Status { + case "running": + if m.agentHasEvents(agent.ID) { + left = m.sweepView() + lipgloss.NewStyle().Foreground(white).Render("esc") + lipgloss.NewStyle().Foreground(dim).Render(" ") + lipgloss.NewStyle().Foreground(dim).Render("stop") + } else { + left = m.sweepView() + lipgloss.NewStyle().Foreground(white).Render("Initializing") + } + right = quitHint + case "waiting": + left = lipgloss.NewStyle().Foreground(dim).Render("Send message to resume") + if msg := agent.ErrorMessage; msg != "" { + left = lipgloss.NewStyle().Foreground(red).Render(msg) + + lipgloss.NewStyle().Foreground(dim).Render(" Β· Send message to resume") + } + case "budget_paused": + left = lipgloss.NewStyle().Foreground(amber).Render("Budget limit reached") + + lipgloss.NewStyle().Foreground(dim).Render(" Β· Send a message to continue") + right = quitHint + case "completed": + left = lipgloss.NewStyle().Foreground(mid).Render("Agent completed") + case "stopped": + left = lipgloss.NewStyle().Foreground(mid).Render("Agent stopped") + case "failed", "crashed": + msg := agent.ErrorMessage + if msg == "" { + msg = "Agent failed" + } + left = lipgloss.NewStyle().Foreground(red).Render(msg) + + lipgloss.NewStyle().Foreground(dim).Render(" Β· Send message to resume") + } + } + if m.errorText != "" { + left = lipgloss.NewStyle().Foreground(red).Render(m.errorText) + } + gap := max(1, width-lipgloss.Width(left)-lipgloss.Width(right)) + return " " + left + strings.Repeat(" ", max(1, gap-1)) + right +} + +func (m Model) sweepView() string { + palette := []lipgloss.Color{ + black, lipgloss.Color("#031a09"), lipgloss.Color("#052e16"), lipgloss.Color("#0d4a2a"), + lipgloss.Color("#15803d"), green, brightGreen, lipgloss.Color("#86efac"), + } + const numSquares = 6 + numColors := len(palette) + offset := numColors - 1 + maxPos := (numSquares - 1) + offset + totalRange := maxPos + offset + cycleLength := totalRange * 2 + frameInCycle := m.sweepFrame % cycleLength + wavePos := totalRange - abs(totalRange-frameInCycle) + sweepPos := wavePos - offset + + dotColor := lipgloss.Color("#0a3d1f") + var b strings.Builder + for i := 0; i < numSquares; i++ { + dist := abs(i - sweepPos) + colorIdx := numColors - 1 - dist + if colorIdx <= 0 { + b.WriteString(lipgloss.NewStyle().Foreground(dotColor).Render("Β·")) + } else { + b.WriteString(lipgloss.NewStyle().Foreground(palette[colorIdx]).Render("β–ͺ")) + } + } + b.WriteString(" ") + return b.String() +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +func titleCase(s string) string { + return strings.Title(strings.ToLower(s)) +} + +// overlay composites a centered dialog on top of the live main view. When +// dimmed is true (vulnerability detail, background: #000000 80%) the backdrop is +// recolored to a dark grey; otherwise it is left untouched to match Textual's +// transparent modal backdrop (background: $background 0%). +func (m Model) overlay(background, foreground string, dimmed bool) string { + bg := strings.Split(background, "\n") + fg := strings.Split(foreground, "\n") + dialogHeight := len(fg) + dialogWidth := lipgloss.Width(foreground) + top := max(0, (m.height-dialogHeight)/2) + left := max(0, (m.width-dialogWidth)/2) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#3f3f46")) + for row := 0; row < len(bg); row++ { + if row < top || row >= top+dialogHeight { + if dimmed { + bg[row] = dimStyle.Render(ansi.Strip(bg[row])) + } + continue + } + fgLine := fg[row-top] + rightStart := left + dialogWidth + var leftPart, rightPart string + if dimmed { + bgLine := ansi.Strip(bg[row]) + leftPart = dimStyle.Render(truncateToWidth(bgLine, left)) + if lipgloss.Width(bgLine) > rightStart { + rightPart = dimStyle.Render(ansi.TruncateLeft(bgLine, rightStart, "")) + } + } else { + // Preserve the original styling of the visible backdrop segments. + leftPart = padToWidth(ansi.Truncate(bg[row], left, ""), left) + if lipgloss.Width(bg[row]) > rightStart { + rightPart = ansi.TruncateLeft(bg[row], rightStart, "") + } + } + bg[row] = leftPart + fgLine + rightPart + } + return strings.Join(bg, "\n") +} + +// padToWidth right-pads an ANSI string to an exact display width. +func padToWidth(value string, width int) string { + w := lipgloss.Width(value) + if w >= width { + return value + } + return value + strings.Repeat(" ", width-w) +} + +func truncateToWidth(value string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(value) <= width { + return value + strings.Repeat(" ", width-lipgloss.Width(value)) + } + return ansi.Truncate(value, width, "") +} diff --git a/strix/interface/tui/internal/app/vuln_report.go b/strix/interface/tui/internal/app/vuln_report.go new file mode 100644 index 00000000..b7749805 --- /dev/null +++ b/strix/interface/tui/internal/app/vuln_report.go @@ -0,0 +1,176 @@ +package app + +// Markdown clipboard report for the vulnerability detail dialog, porting +// VulnerabilityDetailScreen._get_markdown_report plus the report-writer fence +// helpers (safe_fence, guess_language_name). + +import ( + "fmt" + "regexp" + "strings" + + "github.com/alecthomas/chroma/v2/lexers" + + "github.com/usestrix/strix/tui/internal/render" +) + +var backtickRun = regexp.MustCompile("`+") + +// safeFence returns a backtick fence that content cannot break out of: one +// backtick longer than the longest run inside it, never fewer than three. +func safeFence(content string) string { + longest := 0 + for _, run := range backtickRun.FindAllString(content, -1) { + longest = max(longest, len(run)) + } + return strings.Repeat("`", max(3, longest+1)) +} + +// guessLanguageName returns a markdown fence tag for code, defaulting to +// "python" when auto-detection is inconclusive (legacy PoC scripts are Python). +func guessLanguageName(code string) string { + lexer := lexers.Analyse(code) + if lexer == nil { + return "python" + } + config := lexer.Config() + if config == nil || len(config.Aliases) == 0 || config.Name == "plaintext" { + return "python" + } + return config.Aliases[0] +} + +func titleCaseWords(text string) string { + words := strings.Fields(text) + for i, word := range words { + words[i] = titleCase(word) + } + return strings.Join(words, " ") +} + +// vulnerabilityMarkdownReport builds the Markdown vulnerability report copied +// to the clipboard, field-for-field with the old Textual screen. +func vulnerabilityMarkdownReport(v map[string]any) string { + var lines []string + + title := render.StringValue(v["title"]) + if title == "" { + title = "Untitled Vulnerability" + } + lines = append(lines, "# "+title, "") + + field := func(label, value string) { + if value != "" { + lines = append(lines, fmt.Sprintf("**%s:** %s", label, value)) + } + } + field("ID", render.StringValue(v["id"])) + field("Severity", strings.ToUpper(render.StringValue(v["severity"]))) + field("Found", render.StringValue(v["timestamp"])) + field("Agent", render.StringValue(v["agent_name"])) + field("Target", render.StringValue(v["target"])) + if dep, ok := v["dependency_metadata"].(map[string]any); ok { + field("Package", render.StringValue(dep["package_name"])) + field("Ecosystem", render.StringValue(dep["package_ecosystem"])) + field("Installed Version", render.StringValue(dep["installed_version"])) + field("Fixed Version", render.StringValue(dep["fixed_version"])) + } + field("Endpoint", render.StringValue(v["endpoint"])) + field("Method", render.StringValue(v["method"])) + field("CVE", render.StringValue(v["cve"])) + field("CWE", render.StringValue(v["cwe"])) + field("CVSS", render.StringValue(v["cvss"])) + if fe := render.StringValue(v["fix_effort"]); fe != "" { + field("Fix Effort", titleCaseWords(fe)) + } + if bd, ok := v["cvss_breakdown"].(map[string]any); ok && len(bd) > 0 { + if parts := render.CVSSVectorParts(bd); len(parts) > 0 { + field("CVSS Vector", strings.Join(parts, "/")) + } + } + + description := render.StringValue(v["description"]) + if description == "" { + description = "No description provided." + } + lines = append(lines, "", "## Description", "", description) + + section := func(label, value string) { + if value != "" { + lines = append(lines, "", "## "+label, "", value) + } + } + section("Impact", render.StringValue(v["impact"])) + section("Technical Analysis", render.StringValue(v["technical_analysis"])) + section("Evidence", render.StringValue(v["evidence"])) + + pocDescription := render.StringValue(v["poc_description"]) + pocScript := render.StringValue(v["poc_script_code"]) + if pocDescription != "" || pocScript != "" { + lines = append(lines, "", "## Proof of Concept", "") + if pocDescription != "" { + lines = append(lines, pocDescription, "") + } + if pocScript != "" { + pocLang, pocCode := render.ParseFencedCode(pocScript) + if pocLang == "" { + pocLang = guessLanguageName(pocCode) + } + fence := safeFence(pocCode) + lines = append(lines, fence+pocLang, pocCode, fence) + } + } + + if locations, ok := v["code_locations"].([]any); ok && len(locations) > 0 { + lines = append(lines, "", "## Code Analysis", "") + for i, item := range locations { + loc, ok := item.(map[string]any) + if !ok { + continue + } + file := render.StringValue(loc["file"]) + if file == "" { + file = "unknown" + } + lineRef := "" + if start := render.StringValue(loc["start_line"]); start != "" { + if end := render.StringValue(loc["end_line"]); end != "" && end != start { + lineRef = fmt.Sprintf(" (lines %s-%s)", start, end) + } else { + lineRef = fmt.Sprintf(" (line %s)", start) + } + } + lines = append(lines, fmt.Sprintf("**Location %d:** `%s`%s", i+1, file, lineRef)) + if label := render.StringValue(loc["label"]); label != "" { + lines = append(lines, " "+label) + } + if snippet := render.StringValue(loc["snippet"]); snippet != "" { + fence := safeFence(snippet) + lines = append(lines, fence+"\n"+snippet+"\n"+fence) + } + before := render.StringValue(loc["fix_before"]) + after := render.StringValue(loc["fix_after"]) + if before != "" || after != "" { + lines = append(lines, "**Suggested Fix:**", "```diff") + if before != "" { + for _, l := range strings.Split(before, "\n") { + lines = append(lines, "- "+l) + } + } + if after != "" { + for _, l := range strings.Split(after, "\n") { + lines = append(lines, "+ "+l) + } + } + lines = append(lines, "```") + } + lines = append(lines, "") + } + } + + section("Remediation", render.StringValue(v["remediation_steps"])) + section("Assumptions", render.StringValue(v["assumptions"])) + + lines = append(lines, "") + return strings.Join(lines, "\n") +} diff --git a/strix/interface/tui/internal/app/vulnerabilities.go b/strix/interface/tui/internal/app/vulnerabilities.go new file mode 100644 index 00000000..12b057d5 --- /dev/null +++ b/strix/interface/tui/internal/app/vulnerabilities.go @@ -0,0 +1,405 @@ +package app + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/usestrix/strix/tui/internal/render" +) + +var panelSeverityColors = map[string]lipgloss.Color{ + "critical": render.SevCrit, "high": render.SevHigh, "medium": render.SevMed, "low": green, "info": blue, +} + +func (m Model) vulnerabilitiesView(width, height int) string { + var lines []string + start := min(max(0, m.vulnOffset), max(0, len(m.snapshot.Vulnerabilities)-1)) + for i := start; i < len(m.snapshot.Vulnerabilities) && len(lines) < height; i++ { + vuln := m.snapshot.Vulnerabilities[i] + severity := strings.ToLower(render.StringValue(vuln["severity"])) + color, ok := panelSeverityColors[severity] + if !ok { + color = blue // matches SEVERITY_COLORS.get(severity, "#3b82f6") + } + marker := lipgloss.NewStyle().Foreground(color).Render("● ") + style := lipgloss.NewStyle().Foreground(textColor) + if i == m.selectedVuln { + style = style.Bold(true).Foreground(white) + } + for row, titleLine := range m.vulnerabilityTitleLines(i, width) { + if len(lines) >= height { + break + } + prefix := " " + if row == 0 { + prefix = marker + } + lines = append(lines, prefix+style.Render(titleLine)) + } + } + return strings.Join(lines, "\n") +} + +func (m Model) vulnerabilityListWidth() int { + _, sidebarWidth, _, _ := m.layout() + return max(1, sidebarWidth-6) +} + +func (m Model) vulnerabilityTitleLines(index, width int) []string { + title := render.StringValue(m.snapshot.Vulnerabilities[index]["title"]) + if title == "" { + title = "Unknown Vulnerability" + } + return strings.Split(wrapBlock(title, max(1, width-2)), "\n") +} + +func (m Model) vulnerabilityScrollRows() (total, offset int) { + width := m.vulnerabilityListWidth() + for i := range m.snapshot.Vulnerabilities { + rows := len(m.vulnerabilityTitleLines(i, width)) + total += rows + if i < m.vulnOffset { + offset += rows + } + } + return total, offset +} + +func (m Model) vulnerabilityOffsetAtRow(targetRow int) int { + width := m.vulnerabilityListWidth() + row := 0 + for i := range m.snapshot.Vulnerabilities { + row += len(m.vulnerabilityTitleLines(i, width)) + if targetRow < row { + return i + } + } + return max(0, len(m.snapshot.Vulnerabilities)-1) +} + +func (m Model) vulnerabilityVisibleEnd(start int) int { + height := m.vulnerabilityPageSize() + width := m.vulnerabilityListWidth() + rows := 0 + end := min(max(0, start), len(m.snapshot.Vulnerabilities)) + for end < len(m.snapshot.Vulnerabilities) { + itemRows := len(m.vulnerabilityTitleLines(end, width)) + if rows > 0 && rows+itemRows > height { + break + } + rows += itemRows + end++ + if rows >= height { + break + } + } + return end +} + +func (m Model) vulnerabilityIndexAtRow(row int) int { + width := m.vulnerabilityListWidth() + currentRow := 0 + for i := m.vulnOffset; i < m.vulnerabilityVisibleEnd(m.vulnOffset); i++ { + currentRow += len(m.vulnerabilityTitleLines(i, width)) + if row < currentRow { + return i + } + } + return -1 +} + +func (m *Model) ensureVulnerabilityVisible() { + if len(m.snapshot.Vulnerabilities) == 0 { + m.vulnOffset = 0 + return + } + if m.selectedVuln < m.vulnOffset { + m.vulnOffset = m.selectedVuln + } + for m.selectedVuln >= m.vulnerabilityVisibleEnd(m.vulnOffset) && m.vulnOffset < m.selectedVuln { + m.vulnOffset++ + } + m.vulnOffset = min(m.vulnOffset, len(m.snapshot.Vulnerabilities)-1) +} + +func (m Model) vulnerabilityPageSize() int { + _, vulnHeight, _ := m.sidebarHeights() + return max(1, vulnHeight-2) +} + +func (m Model) vulnerabilityPageItems() int { + return max(1, m.vulnerabilityVisibleEnd(m.vulnOffset)-m.vulnOffset) +} + +func (m *Model) moveVulnerabilitySelection(delta int) { + m.selectedVuln = max(0, min(len(m.snapshot.Vulnerabilities)-1, m.selectedVuln+delta)) +} + +func (m *Model) keepVulnerabilitySelectionInWindow() { + if len(m.snapshot.Vulnerabilities) == 0 { + return + } + if m.selectedVuln < m.vulnOffset { + m.selectedVuln = m.vulnOffset + } else if end := m.vulnerabilityVisibleEnd(m.vulnOffset); m.selectedVuln >= end { + m.selectedVuln = max(m.vulnOffset, end-1) + } +} + +// statsView ports build_tui_stats_text + the version line appended in +// _update_stats_display: model, token/cost line, optional Caido URL, version. +func (m Model) modalView() string { + switch m.modal { + case modalHelp: + title := lipgloss.NewStyle().Bold(true).Foreground(green).Width(34).Align(lipgloss.Center).Render("Strix Help") + body := lipgloss.NewStyle().Foreground(textColor).Render("F1 Help\nCtrl+O Open viewer\nCtrl+Q/C Quit\nESC Stop Agent\nEnter Send / expand node\nCtrl+J Newline in message\nTab Switch panels\n↑/↓ Navigate tree\nDrag Select & copy text\nClick Expand/collapse tool") + content := title + "\n\n" + body + return lipgloss.NewStyle().Width(38).Border(lipgloss.RoundedBorder()).BorderForeground(green).Background(black).Padding(1, 2).Render(content) + case modalQuit: + // #quit_dialog: width 24, border round #333333, title #d4d4d4. + return m.confirmView("Quit Strix?", 24, dark, textColor) + case modalStop: + name := "agent" + if len(m.snapshot.Agents) > 0 { + name = m.snapshot.Agents[m.selectedAgent].Name + } + // #stop_agent_dialog: width 30, border round #a3a3a3, title #a3a3a3. + return m.confirmView("πŸ›‘ Stop '"+name+"'?", 30, mid, mid) + case modalConfirmMount: + return m.mountConfirmView() + case modalVulnerability: + if len(m.snapshot.Vulnerabilities) == 0 { + return "" + } + return m.vulnerabilityDetail() + } + return "" +} + +func (m Model) confirmView(title string, width int, border, titleColor lipgloss.Color) string { + return m.confirmDialog(title, "", width, border, titleColor, red, "Yes", "No") +} + +// mountConfirmView asks before a target-less scan mounts the working directory. +// It is a compact prompt docked in the corner of the live view: nothing is +// prepared until it is answered, and the directory is a workspace rather than a +// target, so the prompt is what the scan follows. +func (m Model) mountConfirmView() string { + width := min(52, max(20, m.width-4)) + dir := strings.TrimSpace(m.snapshot.PendingMount) + if dir == "" { + dir = "the current directory" + } + title := render.Bold(amber).Render("β–³ Mount working directory?") + body := render.Col(white).Render(truncatePath(dir, width-4)) + "\n" + + render.Dim().Render("writable in the sandbox") + return m.cornerPrompt(title, body, width, "Confirm", "Cancel") +} + +// truncatePath keeps the tail of a path visible, which is the part that +// identifies the directory. +func truncatePath(path string, width int) string { + if width <= 1 || lipgloss.Width(path) <= width { + return path + } + return "…" + ansi.TruncateLeft(path, lipgloss.Width(path)-width+1, "") +} + +// cornerPrompt renders a compact two-button prompt for the corner of the live +// view, sized to its content rather than centered like the modal dialogs. +func (m Model) cornerPrompt(title, body string, width int, confirmLabel, cancelLabel string) string { + // Each label keeps its padding whether or not it is focused, so moving the + // choice repaints a background instead of shifting the pair sideways. + button := func(label string, focused bool, fill lipgloss.Color) string { + style := lipgloss.NewStyle().Bold(true) + if focused { + return style.Background(fill).Foreground(brightWhite).Render(" " + label + " ") + } + return style.Foreground(fill).Render(" " + label + " ") + } + yes := button(confirmLabel, m.modalChoice == 0, amber) + no := button(cancelLabel, m.modalChoice != 0, dim) + if m.modalChoice != 0 { + no = button(cancelLabel, true, lipgloss.Color("#3e3e3e")) + } + inner := lipgloss.NewStyle().Width(width - 4) + content := inner.Render(title) + "\n" + inner.Render(body) + "\n" + + inner.Align(lipgloss.Right).Render(yes+" "+no) + return lipgloss.NewStyle().Width(width-2).Border(lipgloss.RoundedBorder()). + BorderForeground(amber).Background(black).Padding(0, 1).Render(content) +} + +// confirmDialog renders a two-button prompt. The focused button fills its +// background; body is optional detail shown between the title and the buttons. +func (m Model) confirmDialog( + title, body string, + width int, + border, titleColor, confirmColor lipgloss.Color, + confirmLabel, cancelLabel string, +) string { + // Two equal columns with a one-cell gutter. The buttons keep their columns + // whichever one is focused, so moving the choice repaints a background + // instead of shifting the row. + contentWidth := width - 4 + inner := lipgloss.NewStyle().Width(contentWidth) + // Two columns share the content width with a one-cell gutter; the label + // carries a space on each side before it is centered in its column. + leftColumn := (contentWidth - 1) / 2 + rightColumn := contentWidth - 1 - leftColumn + button := func(label string, column int, focused bool, fill lipgloss.Color) string { + style := lipgloss.NewStyle().Width(column).Align(lipgloss.Center).Bold(true) + if focused { + return style.Background(fill).Foreground(brightWhite).Render(" " + label + " ") + } + return style.Foreground(fill).Render(" " + label + " ") + } + yes := button(confirmLabel, leftColumn, m.modalChoice == 0, confirmColor) + no := button(cancelLabel, rightColumn, false, dim) + if m.modalChoice != 0 { + no = button(cancelLabel, rightColumn, true, lipgloss.Color("#3e3e3e")) + } + content := inner.Bold(true).Foreground(titleColor).Align(lipgloss.Center).Render(title) + if body != "" { + content += "\n\n" + inner.Render(body) + } + content += "\n\n" + inner.Align(lipgloss.Center).Render(yes+" "+no) + // Width() sets the content box, so the border's two columns come off it to + // keep the dialog the width the design calls for. + return lipgloss.NewStyle().Width(width - 2).Border(lipgloss.RoundedBorder()).BorderForeground(border).Background(black).Padding(1).Render(content) +} + +// vulnerabilityBody ports VulnerabilityDetailScreen._render_vulnerability: +// the exact field order, labels, colors, and dict keys. +func vulnerabilityBody(v map[string]any) string { + fieldStyle := render.Bold(render.Field) + var b strings.Builder + b.WriteString("🐞 " + render.Bold(render.ReportHdr).Render("Vulnerability Report")) + + field := func(label, value string) { + if value != "" { + b.WriteString("\n\n" + fieldStyle.Render(label+": ") + value) + } + } + field("Agent", render.StringValue(v["agent_name"])) + field("Title", render.StringValue(v["title"])) + if sev := render.StringValue(v["severity"]); sev != "" { + b.WriteString("\n\n" + fieldStyle.Render("Severity: ") + + lipgloss.NewStyle().Bold(true).Foreground(render.SeverityColor(sev)).Render(strings.ToUpper(sev))) + } + if score, ok := render.NumericValue(v["cvss"]); ok { + b.WriteString("\n\n" + fieldStyle.Render("CVSS Score: ") + + lipgloss.NewStyle().Bold(true).Foreground(render.CVSSColor(score)).Render(render.StringValue(v["cvss"]))) + } + field("Target", render.StringValue(v["target"])) + if dep, ok := v["dependency_metadata"].(map[string]any); ok { + field("Package", render.StringValue(dep["package_name"])) + field("Ecosystem", render.StringValue(dep["package_ecosystem"])) + field("Installed Version", render.StringValue(dep["installed_version"])) + field("Fixed Version", render.StringValue(dep["fixed_version"])) + } + field("Endpoint", render.StringValue(v["endpoint"])) + field("Method", render.StringValue(v["method"])) + field("CVE", render.StringValue(v["cve"])) + field("CWE", render.StringValue(v["cwe"])) + if fe := render.StringValue(v["fix_effort"]); fe != "" { + field("Fix Effort", titleCase(fe)) + } + if bd, ok := v["cvss_breakdown"].(map[string]any); ok && len(bd) > 0 { + if parts := render.CVSSVectorParts(bd); len(parts) > 0 { + b.WriteString("\n\n" + fieldStyle.Render("CVSS Vector: ") + render.Dim().Render(strings.Join(parts, "/"))) + } + } + + section := func(label, value string) { + if value != "" { + b.WriteString("\n\n" + fieldStyle.Render(label) + "\n" + value) + } + } + section("Description", render.StringValue(v["description"])) + section("Impact", render.StringValue(v["impact"])) + section("Technical Analysis", render.StringValue(v["technical_analysis"])) + section("Evidence", render.StringValue(v["evidence"])) + section("PoC Description", render.StringValue(v["poc_description"])) + if poc := render.StringValue(v["poc_script_code"]); poc != "" { + pocLang, pocCode := render.ParseFencedCode(poc) + b.WriteString("\n\n" + fieldStyle.Render("PoC Code") + "\n" + render.HighlightCode(pocCode, pocLang)) + } + section("Remediation", render.StringValue(v["remediation_steps"])) + section("Assumptions", render.StringValue(v["assumptions"])) + return b.String() +} + +func (m Model) vulnerabilityDialogSize() (width, height int) { + return min(m.width, min(110, max(40, m.width*85/100))), min(m.height, min(45, max(10, m.height*85/100))) +} + +func (m *Model) resizeVulnerabilityViewport() { + if m.modal != modalVulnerability || len(m.snapshot.Vulnerabilities) == 0 { + return + } + width, height := m.vulnerabilityDialogSize() + innerWidth := max(1, width-8) // border plus three cells of horizontal padding + m.vulnViewport.Width = max(1, innerWidth-2) // right padding and one-cell scrollbar + m.vulnViewport.Height = max(1, height-9) // padding, one-row grid gutter, and two-row footer + m.vulnViewport.SetContent(wrapBlock(vulnerabilityBody(m.snapshot.Vulnerabilities[m.selectedVuln]), m.vulnViewport.Width)) + m.vulnViewport.SetYOffset(m.vulnViewport.YOffset) +} + +func (m Model) vulnerabilityScrollView() string { + view := m.vulnViewport.View() + if m.vulnViewport.TotalLineCount() <= m.vulnViewport.VisibleLineCount() { + return view + " " + } + height := m.vulnViewport.Height + thumbHeight := max(1, height*m.vulnViewport.VisibleLineCount()/m.vulnViewport.TotalLineCount()) + thumbStart := int(m.vulnViewport.ScrollPercent() * float64(height-thumbHeight)) + bar := make([]string, height) + for row := range bar { + cell := " " + if row >= thumbStart && row < thumbStart+thumbHeight { + cell = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040")).Render("β–ˆ") + } + bar[row] = cell + } + return lipgloss.JoinHorizontal(lipgloss.Top, view, " ", strings.Join(bar, "\n")) +} + +func (m Model) vulnerabilityDetail() string { + width, height := m.vulnerabilityDialogSize() + inner := max(1, width-8) + // Button row: right-aligned Copy / Done above a top rule (#vuln_detail_buttons). + rule := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a1a")).Render(strings.Repeat("─", max(1, inner))) + copyLabel := "Copy" + if m.vulnerabilityCopied { + copyLabel = "Copied!" + } else if m.vulnerabilityCopyError != "" { + copyLabel = "Copy failed" + } + copyButton := lipgloss.NewStyle().Foreground(lipgloss.Color("#525252")) + doneButton := lipgloss.NewStyle().Foreground(mid) + if m.modalChoice == 0 { + copyButton = copyButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1) + } else { + doneButton = doneButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1) + } + buttons := copyButton.Render(copyLabel) + " " + doneButton.Render("Done") + buttonRow := rule + "\n" + lipgloss.NewStyle().Width(inner).Align(lipgloss.Right).Render(buttons) + content := m.vulnerabilityScrollView() + "\n" + buttonRow + return lipgloss.NewStyle().Width(width-2).Height(height-2).Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#262626")).Background(lipgloss.Color("#0a0a0a")).Padding(2, 3).Render(content) +} + +func (m *Model) startVulnerabilityCopy() tea.Cmd { + m.vulnerabilityCopied = false + m.vulnerabilityCopyError = "" + if m.selectedVuln < 0 || m.selectedVuln >= len(m.snapshot.Vulnerabilities) { + return nil + } + report := vulnerabilityMarkdownReport(m.snapshot.Vulnerabilities[m.selectedVuln]) + return func() tea.Msg { + return vulnerabilityCopiedMsg{err: writeClipboard(report)} + } +} + +// titleCase upper-cases the first letter of each word (Python str.title()). diff --git a/strix/interface/tui/internal/app/wire.go b/strix/interface/tui/internal/app/wire.go new file mode 100644 index 00000000..b1bb7337 --- /dev/null +++ b/strix/interface/tui/internal/app/wire.go @@ -0,0 +1,467 @@ +package app + +import ( + "encoding/json" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/usestrix/strix/tui/internal/protocol" + "github.com/usestrix/strix/tui/internal/render" +) + +func (m *Model) handleEnvelope(envelope protocol.Envelope) tea.Cmd { + switch envelope.Type { + case "state": + var update protocol.StateUpdate + if err := json.Unmarshal(envelope.Payload, &update); err != nil { + m.errorText = err.Error() + return nil + } + if update.Revision <= m.stateRevision { + return nil + } + selectedAgentID := "" + if m.selectedAgent >= 0 && m.selectedAgent < len(m.snapshot.Agents) { + selectedAgentID = m.snapshot.Agents[m.selectedAgent].ID + } + update.State.Events = m.snapshot.Events + update.State.Vulnerabilities = m.snapshot.Vulnerabilities + update.State.Agents = m.snapshot.Agents + m.consumeMessages(update.State.Messages, update.State.SetupMode) + m.snapshot = update.State + m.stateRevision = update.Revision + if m.snapshot.Error != nil { + m.errorText = *m.snapshot.Error + } + if m.snapshot.SetupMode { + // The start screen is its own landing page; never sit on the + // splash before it. + m.showSplash = false + m.input.Placeholder = setupPlaceholder + } else { + m.input.Placeholder = chatPlaceholder + } + m.selectedAgent = selectedAgentIndex(m.snapshot.Agents, selectedAgentID) + m.selectedVuln = min(m.selectedVuln, max(0, len(m.snapshot.Vulnerabilities)-1)) + if m.modal == modalStop && !m.selectedAgentCanStop() { + m.closeModal() + } + m.syncMountPrompt() + m.ensureAgentVisible() + m.ensureVulnerabilityVisible() + m.ready = true + // resize (not just refresh): status-row visibility changes the chat height. + m.resizeViewport() + m.resizeVulnerabilityViewport() + case "collection_bootstrap": + return m.handleCollectionBootstrap(envelope.Payload) + case "collection_delta": + return m.handleCollectionDelta(envelope.Payload) + case "command_result": + if m.client == nil { + return nil + } + expectedCommand, pending := m.client.ExpectedCommand(envelope.RequestID) + if !pending { + return nil + } + var result protocol.CommandResult + if err := json.Unmarshal(envelope.Payload, &result); err != nil { + m.errorText = err.Error() + return nil + } + if result.Command != expectedCommand || !m.client.Resolve(envelope.RequestID, result.Command) { + return nil + } + if !result.OK { + if result.Command == "collection.resync" { + if collection := m.resyncRequests[envelope.RequestID]; collection != "" { + m.resyncRequested[collection] = false + delete(m.resyncRequests, envelope.RequestID) + } + } + message := "Command failed" + if result.Error != nil && strings.TrimSpace(result.Error.Message) != "" { + message = result.Error.Message + } + // Setup-mode errors live in the scrollback (red), like Python; during + // a scan they surface on the status line. + if m.snapshot.SetupMode { + m.setupMsg(message, render.Col(red)) + } else { + m.errorText = message + } + return nil + } + if m.snapshot.ScanStarted && !m.snapshot.SetupMode && strings.HasPrefix(result.Command, "setup.") { + return nil + } + m.errorText = "" + switch result.Command { + case "viewer.open": + var data struct { + Status string `json:"status"` + URL *string `json:"url"` + } + _ = json.Unmarshal(result.Result, &data) + m.snapshot.ViewerStatus = data.Status + m.snapshot.ViewerURL = data.URL + } + } + return nil +} + +func (m *Model) consumeMessages(messages []protocol.Message, setupMode bool) { + if m.seenMessages == nil { + m.seenMessages = map[string]bool{} + } + for _, message := range messages { + key := message.ID + if key == "" { + key = message.Level + "\x00" + message.Text + } + if m.seenMessages[key] { + continue + } + m.seenMessages[key] = true + if !setupMode || strings.TrimSpace(message.Text) == "" { + continue + } + style := render.Dim() + switch message.Level { + case "error": + style = render.Col(red) + case "warning": + style = render.Col(amber) + } + m.setupMsg(message.Text, style) + } +} + +func validCollection(name string) bool { + return name == "agents" || name == "events" || name == "vulnerabilities" +} + +func (m *Model) collectionMismatch(name string) tea.Cmd { + delete(m.collectionAssemblies, name) + if !validCollection(name) || m.resyncRequested[name] || m.client == nil { + return nil + } + m.resyncRequested[name] = true + return send(m.client, "collection.resync", map[string]any{"collection": name}) +} + +func (m *Model) clearCollectionResync(name string) { + m.resyncRequested[name] = false + for requestID, collection := range m.resyncRequests { + if collection == name { + delete(m.resyncRequests, requestID) + } + } +} + +func (m *Model) handleCollectionBootstrap(payload json.RawMessage) tea.Cmd { + var chunk protocol.CollectionBootstrap + if err := json.Unmarshal(payload, &chunk); err != nil { + m.errorText = err.Error() + return nil + } + if !validCollection(chunk.Collection) { + m.errorText = "Unknown collection: " + chunk.Collection + return nil + } + if chunk.Cursor == 0 { + m.resyncRequested[chunk.Collection] = false + } + if chunk.Cursor == 0 { + if chunk.Revision <= m.collectionRevisions[chunk.Collection] { + return nil + } + m.collectionAssemblies[chunk.Collection] = &collectionAssembly{ + kind: "bootstrap", revision: chunk.Revision, ids: map[string]bool{}, + } + } + assembly := m.collectionAssemblies[chunk.Collection] + if assembly == nil || assembly.kind != "bootstrap" || assembly.revision != chunk.Revision || assembly.cursor != chunk.Cursor { + return m.collectionMismatch(chunk.Collection) + } + if chunk.NextCursor != chunk.Cursor+len(chunk.Items) { + return m.collectionMismatch(chunk.Collection) + } + for _, raw := range chunk.Items { + if chunk.Collection == "agents" { + var agent protocol.Agent + if err := json.Unmarshal(raw, &agent); err != nil || agent.ID == "" { + return m.collectionMismatch(chunk.Collection) + } + if assembly.ids[agent.ID] { + return m.collectionMismatch(chunk.Collection) + } + assembly.ids[agent.ID] = true + assembly.agents = append(assembly.agents, agent) + } else if chunk.Collection == "events" { + var event protocol.Event + if err := json.Unmarshal(raw, &event); err != nil || event.ID == "" { + return m.collectionMismatch(chunk.Collection) + } + if assembly.ids[event.ID] { + return m.collectionMismatch(chunk.Collection) + } + assembly.ids[event.ID] = true + assembly.events = append(assembly.events, event) + } else { + var finding map[string]any + if err := json.Unmarshal(raw, &finding); err != nil || collectionItemID(finding) == "" { + return m.collectionMismatch(chunk.Collection) + } + id := collectionItemID(finding) + if assembly.ids[id] { + return m.collectionMismatch(chunk.Collection) + } + assembly.ids[id] = true + assembly.findings = append(assembly.findings, finding) + } + } + assembly.cursor = chunk.NextCursor + if !chunk.Done { + return nil + } + if chunk.Collection == "agents" { + selectedAgentID := m.selectedAgentID() + m.snapshot.Agents = assembly.agents + m.selectedAgent = selectedAgentIndex(m.snapshot.Agents, selectedAgentID) + } else if chunk.Collection == "events" { + m.snapshot.Events = assembly.events + } else { + m.snapshot.Vulnerabilities = assembly.findings + } + m.collectionRevisions[chunk.Collection] = chunk.Revision + delete(m.collectionAssemblies, chunk.Collection) + m.clearCollectionResync(chunk.Collection) + return m.refreshAfterCollection(chunk.Collection) +} + +func (m *Model) handleCollectionDelta(payload json.RawMessage) tea.Cmd { + var chunk protocol.CollectionDelta + if err := json.Unmarshal(payload, &chunk); err != nil { + m.errorText = err.Error() + return nil + } + if !validCollection(chunk.Collection) { + m.errorText = "Unknown collection: " + chunk.Collection + return nil + } + if chunk.Cursor == 0 { + if chunk.BaseRevision != m.collectionRevisions[chunk.Collection] || chunk.Revision <= chunk.BaseRevision { + return m.collectionMismatch(chunk.Collection) + } + m.collectionAssemblies[chunk.Collection] = &collectionAssembly{ + kind: "delta", revision: chunk.Revision, baseRevision: chunk.BaseRevision, + } + } + assembly := m.collectionAssemblies[chunk.Collection] + if assembly == nil || assembly.kind != "delta" || assembly.revision != chunk.Revision || + assembly.baseRevision != chunk.BaseRevision || assembly.cursor != chunk.Cursor { + return m.collectionMismatch(chunk.Collection) + } + if chunk.NextCursor != chunk.Cursor+len(chunk.Operations) { + return m.collectionMismatch(chunk.Collection) + } + assembly.operations = append(assembly.operations, chunk.Operations...) + assembly.cursor = chunk.NextCursor + if !chunk.Done { + return nil + } + if !m.applyCollectionOperations(chunk.Collection, assembly.operations) { + return m.collectionMismatch(chunk.Collection) + } + m.collectionRevisions[chunk.Collection] = chunk.Revision + delete(m.collectionAssemblies, chunk.Collection) + m.clearCollectionResync(chunk.Collection) + return m.refreshAfterCollection(chunk.Collection) +} + +func (m *Model) applyCollectionOperations(name string, operations []protocol.CollectionOperation) bool { + seen := make(map[string]bool, len(operations)) + if name == "agents" { + selectedAgentID := m.selectedAgentID() + values := append([]protocol.Agent(nil), m.snapshot.Agents...) + positions := make(map[string]int, len(values)) + for index, agent := range values { + positions[agent.ID] = index + } + for _, operation := range operations { + if operation.Op == "delete" { + if operation.ID == "" || seen[operation.ID] { + return false + } + seen[operation.ID] = true + index, exists := positions[operation.ID] + if !exists { + return false + } + values = append(values[:index], values[index+1:]...) + positions = make(map[string]int, len(values)) + for position, value := range values { + positions[value.ID] = position + } + continue + } + if operation.Op != "upsert" { + return false + } + var agent protocol.Agent + if err := json.Unmarshal(operation.Item, &agent); err != nil || agent.ID == "" || seen[agent.ID] { + return false + } + seen[agent.ID] = true + if index, exists := positions[agent.ID]; exists { + values[index] = agent + } else { + positions[agent.ID] = len(values) + values = append(values, agent) + } + } + m.snapshot.Agents = values + m.selectedAgent = selectedAgentIndex(values, selectedAgentID) + return true + } + if name == "events" { + values := append([]protocol.Event(nil), m.snapshot.Events...) + positions := make(map[string]int, len(values)) + for index, event := range values { + positions[event.ID] = index + } + for _, operation := range operations { + if operation.Op == "delete" { + if operation.ID == "" || seen[operation.ID] { + return false + } + seen[operation.ID] = true + index, exists := positions[operation.ID] + if !exists { + return false + } + values = append(values[:index], values[index+1:]...) + positions = make(map[string]int, len(values)) + for position, value := range values { + positions[value.ID] = position + } + continue + } + if operation.Op != "upsert" { + return false + } + var event protocol.Event + if err := json.Unmarshal(operation.Item, &event); err != nil || event.ID == "" || event.Version < 0 || seen[event.ID] { + return false + } + seen[event.ID] = true + if index, exists := positions[event.ID]; exists { + current := values[index] + if event.Version <= current.Version { + return false + } + values[index] = event + } else { + positions[event.ID] = len(values) + values = append(values, event) + } + } + m.snapshot.Events = values + return true + } + + values := append([]map[string]any(nil), m.snapshot.Vulnerabilities...) + positions := make(map[string]int, len(values)) + for index, finding := range values { + positions[collectionItemID(finding)] = index + } + for _, operation := range operations { + if operation.Op == "delete" { + if operation.ID == "" || seen[operation.ID] { + return false + } + seen[operation.ID] = true + index, exists := positions[operation.ID] + if !exists { + return false + } + values = append(values[:index], values[index+1:]...) + positions = make(map[string]int, len(values)) + for position, value := range values { + positions[collectionItemID(value)] = position + } + continue + } + if operation.Op != "upsert" { + return false + } + var finding map[string]any + if err := json.Unmarshal(operation.Item, &finding); err != nil { + return false + } + id := collectionItemID(finding) + if id == "" { + return false + } + if seen[id] { + return false + } + seen[id] = true + if index, exists := positions[id]; exists { + values[index] = finding + } else { + positions[id] = len(values) + values = append(values, finding) + } + } + m.snapshot.Vulnerabilities = values + return true +} + +func collectionItemID(item map[string]any) string { + id, _ := item["id"].(string) + return id +} + +func (m *Model) refreshAfterCollection(name string) tea.Cmd { + if name == "agents" { + m.ensureAgentVisible() + m.refreshViewport() + return m.notifyBudgetPause() + } + if name == "events" { + m.refreshViewport() + return nil + } + m.selectedVuln = min(m.selectedVuln, max(0, len(m.snapshot.Vulnerabilities)-1)) + m.ensureVulnerabilityVisible() + m.resizeVulnerabilityViewport() + return nil +} + +// notifyBudgetPause ports _notify_budget_pause: a one-shot warning toast when +// any agent hits the budget limit, re-armed once no agent is paused. +func (m *Model) notifyBudgetPause() tea.Cmd { + paused := false + for _, agent := range m.snapshot.Agents { + if agent.Status == "budget_paused" { + paused = true + break + } + } + if paused && !m.budgetPauseNotified { + m.budgetPauseNotified = true + return m.showToastFor( + "Budget limit reached β€” agents paused. Send a message to continue "+ + "(this extends the budget), or ctrl-q to quit.", + 15*time.Second, + ) + } + if !paused { + m.budgetPauseNotified = false + } + return nil +} diff --git a/strix/interface/tui/internal/protocol/protocol.go b/strix/interface/tui/internal/protocol/protocol.go new file mode 100644 index 00000000..38ba63a3 --- /dev/null +++ b/strix/interface/tui/internal/protocol/protocol.go @@ -0,0 +1,118 @@ +package protocol + +import "encoding/json" + +const Version = 3 + +var Capabilities = []string{ + "state-revisions", + "collection-deltas", + "structured-command-errors", + "agents-collection", +} + +type Envelope struct { + Version int `json:"version"` + Type string `json:"type"` + RequestID string `json:"request_id,omitempty"` + Payload json.RawMessage `json:"payload"` +} + +type Message struct { + ID string `json:"id"` + Text string `json:"text"` + Level string `json:"level"` +} + +type Agent struct { + ID string `json:"id"` + Name string `json:"name"` + ParentID *string `json:"parent_id"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` +} + +type Event struct { + ID string `json:"id"` + Type string `json:"type"` + AgentID string `json:"agent_id"` + Timestamp string `json:"timestamp"` + Version int `json:"version"` + Data map[string]any `json:"data"` +} + +type Hello struct { + Capabilities []string `json:"capabilities"` +} + +type Snapshot struct { + SetupMode bool `json:"setup_mode"` + ScanStarted bool `json:"scan_started"` + ScanState string `json:"scan_state"` + Targets []string `json:"targets"` + TargetCount int `json:"target_count"` + WorkingDir string `json:"working_dir"` + PendingMount string `json:"pending_mount"` + Instruction string `json:"instruction"` + ScanMode string `json:"scan_mode"` + MaxBudgetUSD *float64 `json:"max_budget_usd"` + MaxTurns int `json:"max_turns"` + ScopeMode string `json:"scope_mode"` + DiffBase string `json:"diff_base"` + Model string `json:"model"` + ModelWarning string `json:"model_warning"` + CaidoURL string `json:"caido_url"` + Messages []Message `json:"messages"` + Agents []Agent `json:"-"` + Events []Event `json:"-"` + Vulnerabilities []map[string]any `json:"-"` + Usage map[string]any `json:"usage"` + Subscription bool `json:"subscription"` + ViewerStatus string `json:"viewer_status"` + ViewerURL *string `json:"viewer_url"` + Error *string `json:"error"` + ProjectionTruncated bool `json:"projection_truncated"` +} + +type StateUpdate struct { + Revision int `json:"revision"` + State Snapshot `json:"state"` +} + +type CollectionBootstrap struct { + Collection string `json:"collection"` + Revision int `json:"revision"` + Cursor int `json:"cursor"` + NextCursor int `json:"next_cursor"` + Done bool `json:"done"` + Items []json.RawMessage `json:"items"` +} + +type CollectionOperation struct { + Op string `json:"op"` + ID string `json:"id,omitempty"` + Item json.RawMessage `json:"item"` +} + +type CollectionDelta struct { + Collection string `json:"collection"` + BaseRevision int `json:"base_revision"` + Revision int `json:"revision"` + Cursor int `json:"cursor"` + NextCursor int `json:"next_cursor"` + Done bool `json:"done"` + Operations []CollectionOperation `json:"operations"` +} + +type CommandError struct { + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable"` +} + +type CommandResult struct { + OK bool `json:"ok"` + Command string `json:"command"` + Result json.RawMessage `json:"result"` + Error *CommandError `json:"error"` +} diff --git a/strix/interface/tui/internal/protocol/protocol_test.go b/strix/interface/tui/internal/protocol/protocol_test.go new file mode 100644 index 00000000..f2f539b6 --- /dev/null +++ b/strix/interface/tui/internal/protocol/protocol_test.go @@ -0,0 +1,22 @@ +package protocol + +import ( + "reflect" + "testing" +) + +func TestProtocolVersionAndCapabilities(t *testing.T) { + if Version != 3 { + t.Fatalf("protocol version = %d, want 3", Version) + } + wantCapabilities := []string{ + "state-revisions", + "collection-deltas", + "structured-command-errors", + "agents-collection", + } + if !reflect.DeepEqual(Capabilities, wantCapabilities) { + t.Fatalf("capabilities = %#v, want %#v", Capabilities, wantCapabilities) + } + +} diff --git a/strix/interface/tui/internal/render/agent_message.go b/strix/interface/tui/internal/render/agent_message.go new file mode 100644 index 00000000..84715223 --- /dev/null +++ b/strix/interface/tui/internal/render/agent_message.go @@ -0,0 +1,320 @@ +package render + +import ( + "regexp" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// Markdown (agent_message_renderer.py) +// --------------------------------------------------------------------------- + +var blankLineRuns = regexp.MustCompile(`\n\s*\n`) + +type mdHeader struct { + prefix string + strip int + style lipgloss.Style +} + +var mdHeaders = []mdHeader{ + {"###### ", 7, Bold(Field)}, + {"##### ", 6, Bold(Green)}, + {"#### ", 5, Bold(Hdr16a)}, + {"### ", 4, Bold(Hdr158)}, + {"## ", 3, Bold(Green)}, + {"# ", 2, Bold(Field)}, +} + +// renderAssistantMarkdown ports AgentMessageRenderer.render_simple + helpers. +func renderAssistantMarkdown(content string) string { + if content == "" { + return "" + } + cleaned := strings.TrimSpace(blankLineRuns.ReplaceAllString(content, "\n\n")) + if cleaned == "" { + return "" + } + return applyMarkdownStyles(cleaned) +} + +func applyMarkdownStyles(text string) string { + var out strings.Builder + lines := strings.Split(text, "\n") + + inCode := false + codeLang := "" + var codeLines []string + + flushCode := func() { + if len(codeLines) > 0 { + out.WriteString(HighlightCode(strings.Join(codeLines, "\n"), codeLang)) + } + codeLines = nil + codeLang = "" + } + + for i := 0; i < len(lines); i++ { + line := lines[i] + if i > 0 && !inCode { + out.WriteString("\n") + } + + if !inCode { + if rows := tableRows(lines[i:]); rows > 0 { + out.WriteString(renderMarkdownTable(lines[i : i+rows])) + i += rows - 1 + continue + } + } + + if strings.HasPrefix(line, "```") { + if !inCode { + inCode = true + codeLines = nil + codeLang = strings.TrimSpace(strings.TrimPrefix(line, "```")) + if i > 0 { + out.WriteString("\n") + } + } else { + inCode = false + flushCode() + } + continue + } + + if inCode { + codeLines = append(codeLines, line) + continue + } + + if h := tryHeader(line); h != nil { + out.WriteString(h.style.Render(line[h.strip:])) + continue + } + switch { + case strings.HasPrefix(line, "> "): + out.WriteString(Col(Green).Render("┃ ") + inlineFormat(line[2:])) + case strings.HasPrefix(line, "- "), strings.HasPrefix(line, "* "): + out.WriteString(Col(Green).Render("β€’ ") + inlineFormat(line[2:])) + case len(line) > 2 && line[0] >= '0' && line[0] <= '9' && (line[1:3] == ". " || line[1:3] == ") "): + out.WriteString(Col(Green).Render(string(line[0])+". ") + inlineFormat(line[2:])) + case line == "---" || line == "***" || line == "___": + out.WriteString(Col(Green).Render(strings.Repeat("─", 40))) + default: + out.WriteString(inlineFormat(line)) + } + } + + if inCode && len(codeLines) > 0 { + flushCode() + } + return out.String() +} + +func isTableRow(line string) bool { + trimmed := strings.TrimSpace(line) + return strings.HasPrefix(trimmed, "|") && strings.Count(trimmed, "|") >= 2 +} + +var tableSeparatorCell = regexp.MustCompile(`^:?-+:?$`) + +func isTableSeparator(line string) bool { + if !isTableRow(line) { + return false + } + cells := splitTableRow(line) + if len(cells) == 0 { + return false + } + for _, cell := range cells { + if !tableSeparatorCell.MatchString(strings.TrimSpace(cell)) { + return false + } + } + return true +} + +// tableRows returns how many leading lines form a markdown table (header, +// separator, then body rows), or 0 when the block is not a table. +func tableRows(lines []string) int { + if len(lines) < 2 || !isTableRow(lines[0]) || !isTableSeparator(lines[1]) { + return 0 + } + rows := 2 + for rows < len(lines) && isTableRow(lines[rows]) && !isTableSeparator(lines[rows]) { + rows++ + } + return rows +} + +func splitTableRow(line string) []string { + trimmed := strings.TrimSpace(line) + trimmed = strings.TrimPrefix(trimmed, "|") + trimmed = strings.TrimSuffix(trimmed, "|") + cells := strings.Split(trimmed, "|") + for i := range cells { + cells[i] = strings.TrimSpace(cells[i]) + } + return cells +} + +// renderMarkdownTable draws a column-aligned table: bold header, a rule under +// it, and inline-formatted body cells. +func renderMarkdownTable(lines []string) string { + headerStyle := func(cell string) string { return Bold(Field).Render(cell) } + rows := make([][]string, 0, len(lines)-1) + styleCells := func(line string, style func(string) string) []string { + cells := splitTableRow(line) + for i := range cells { + cells[i] = style(cells[i]) + } + return cells + } + rows = append(rows, styleCells(lines[0], headerStyle)) + for _, line := range lines[2:] { + rows = append(rows, styleCells(line, inlineFormat)) + } + + widths := make([]int, len(rows[0])) + for _, cells := range rows { + for i, cell := range cells { + if i < len(widths) { + widths[i] = max(widths[i], lipgloss.Width(cell)) + } + } + } + + formatRow := func(cells []string) string { + parts := make([]string, len(widths)) + for i := range widths { + cell := "" + if i < len(cells) { + cell = cells[i] + } + parts[i] = cell + strings.Repeat(" ", max(0, widths[i]-lipgloss.Width(cell))) + } + return strings.TrimRight(strings.Join(parts, Dim().Render(" β”‚ ")), " ") + } + + out := []string{formatRow(rows[0])} + rule := make([]string, len(widths)) + for i, width := range widths { + rule[i] = strings.Repeat("─", width) + } + out = append(out, Dim().Render(strings.Join(rule, "─┼─"))) + for _, cells := range rows[1:] { + out = append(out, formatRow(cells)) + } + return strings.Join(out, "\n") +} + +func tryHeader(line string) *mdHeader { + for i := range mdHeaders { + if strings.HasPrefix(line, mdHeaders[i].prefix) { + return &mdHeaders[i] + } + } + return nil +} + +func isWordByte(b byte) bool { + return b == '_' || b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' +} + +// canOpenEmphasis reports whether an emphasis run starting at i (with the +// given marker width) follows CommonMark-style flanking rules: it must not +// sit inside a word and must be followed by a non-space. +func canOpenEmphasis(line string, i, width int) bool { + if i > 0 && isWordByte(line[i-1]) { + return false + } + // Underscores appear inside identifiers far more often than as emphasis, + // so they only open at a word boundary. + if i > 0 && line[i] == '_' && line[i-1] != ' ' && line[i-1] != '\t' { + return false + } + after := i + width + return after < len(line) && line[after] != ' ' && line[after] != '\t' +} + +// canCloseEmphasis reports whether an emphasis run ending at end (marker +// starts at end) is preceded by a non-space and not followed by a word. +func canCloseEmphasis(line string, end, width int) bool { + if end > 0 && (line[end-1] == ' ' || line[end-1] == '\t') { + return false + } + after := end + width + return after >= len(line) || !isWordByte(line[after]) +} + +// findEmphasisEnd locates the closing marker for an emphasis span opened at +// i, honoring the flanking rules; returns -1 when the span should be treated +// as literal text. +func findEmphasisEnd(line string, i int, marker string) int { + from := i + len(marker) + for { + end := strings.Index(line[from:], marker) + if end == -1 { + return -1 + } + end += from + if end == i+len(marker) { + return -1 + } + if canCloseEmphasis(line, end, len(marker)) { + return end + } + from = end + 1 + } +} + +// inlineFormat ports _process_inline_formatting. +func inlineFormat(line string) string { + var out strings.Builder + i, n := 0, len(line) + for i < n { + if i+1 < n && (line[i:i+2] == "**" || line[i:i+2] == "__") { + marker := line[i : i+2] + if canOpenEmphasis(line, i, 2) { + if end := findEmphasisEnd(line, i, marker); end != -1 { + out.WriteString(Bold(Field).Render(line[i+2 : end])) + i = end + 2 + continue + } + } + } + if i+1 < n && line[i:i+2] == "~~" { + if canOpenEmphasis(line, i, 2) { + if end := findEmphasisEnd(line, i, "~~"); end != -1 { + out.WriteString(lipgloss.NewStyle().Strikethrough(true).Foreground(Strike).Render(line[i+2 : end])) + i = end + 2 + continue + } + } + } + if line[i] == '`' { + if end := strings.Index(line[i+1:], "`"); end != -1 { + end += i + 1 + out.WriteString(lipgloss.NewStyle().Bold(true).Foreground(Green).Background(CodeBg).Render(line[i+1 : end])) + i = end + 1 + continue + } + } + if line[i] == '*' || line[i] == '_' { + marker := string(line[i]) + if i+1 < n && line[i+1] != line[i] && canOpenEmphasis(line, i, 1) { + if end := findEmphasisEnd(line, i, marker); end != -1 && (end+1 >= n || line[end+1] != line[i]) { + out.WriteString(lipgloss.NewStyle().Italic(true).Foreground(Mint).Render(line[i+1 : end])) + i = end + 1 + continue + } + } + } + out.WriteByte(line[i]) + i++ + } + return out.String() +} diff --git a/strix/interface/tui/internal/render/agents_graph.go b/strix/interface/tui/internal/render/agents_graph.go new file mode 100644 index 00000000..2a83448b --- /dev/null +++ b/strix/interface/tui/internal/render/agents_graph.go @@ -0,0 +1,86 @@ +package render + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// Agents graph (agents_graph_renderer.py) +// --------------------------------------------------------------------------- + +func renderAgentGraphTool(name string, args map[string]any, result any) string { + var b strings.Builder + switch name { + case "view_agent_graph": + b.WriteString(Col(Lavender).Render("β—‡ ") + Dim().Render("viewing agents graph")) + case "create_agent": + agentName := StringValue(args["name"]) + if agentName == "" { + agentName = "Agent" + } + b.WriteString(Col(Lavender).Render("β—ˆ ") + Dim().Render("spawning ") + Bold(Lavender).Render(agentName)) + if task := StringValue(args["task"]); task != "" { + b.WriteString("\n " + Dim().Render(task)) + } + case "send_message_to_agent": + b.WriteString(Col(InfoBlue).Render("β†’ ")) + if target := StringValue(args["target_agent_id"]); target != "" { + b.WriteString(Dim().Render("to " + target)) + } else { + b.WriteString(Dim().Render("sending message")) + } + if msg := StringValue(args["message"]); msg != "" { + b.WriteString("\n " + Dim().Render(msg)) + } + case "agent_finish": + success := true + if v, ok := args["success"].(bool); ok { + success = v + } + if success { + b.WriteString(Col(Green).Render("β—† ") + Bold(Green).Render("Agent completed")) + } else { + b.WriteString(Col(Red).Render("β—† ") + Bold(Red).Render("Agent failed")) + } + if summary := StringValue(args["result_summary"]); summary != "" { + b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render(summary)) + if findings, ok := args["findings"].([]any); ok { + for _, f := range findings { + b.WriteString("\n β€’ " + Dim().Render(StringValue(f))) + } + } + } else { + b.WriteString("\n " + Dim().Render("Completing task...")) + } + case "wait_for_agents": + b.WriteString(Col(Gray).Render("β—‹ ") + Dim().Render("waiting")) + if reason := StringValue(args["reason"]); reason != "" { + b.WriteString("\n " + Dim().Render(reason)) + } + case "stop_agent": + b.WriteString(Col(Red).Render("β—Ό ") + Dim().Render("stopping")) + if target := StringValue(args["target_agent_id"]); target != "" { + b.WriteString(Bold(Red).Render(" " + target)) + } + cascade := true + if v, ok := args["cascade"].(bool); ok { + cascade = v + } + if cascade { + b.WriteString(Dim().Italic(true).Render(" + descendants")) + } + if reason := StringValue(args["reason"]); reason != "" { + b.WriteString("\n " + Dim().Render(reason)) + } + if m, ok := result.(map[string]any); ok { + if s, hs := m["success"].(bool); hs && !s { + if e := StringValue(m["error"]); e != "" { + b.WriteString("\n " + Col(Red).Render(e)) + } + } + } + } + return b.String() +} diff --git a/strix/interface/tui/internal/render/chat.go b/strix/interface/tui/internal/render/chat.go new file mode 100644 index 00000000..bfc42728 --- /dev/null +++ b/strix/interface/tui/internal/render/chat.go @@ -0,0 +1,32 @@ +package render + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// Chat messages +// --------------------------------------------------------------------------- + +// renderUserMessage ports UserMessageRenderer._format_user_message. +func renderUserMessage(content string) string { + bar := Col(Blue).Render("▍") + var b strings.Builder + b.WriteString(bar + " " + lipgloss.NewStyle().Bold(true).Render("You:")) + for _, line := range strings.Split(content, "\n") { + b.WriteString("\n" + bar + " " + line) + } + return b.String() +} + +// renderChat renders a chat event (assistant markdown or user message). +func Chat(data map[string]any) string { + role, _ := data["role"].(string) + content := StripControls(StringValue(data["content"])) + if role == "user" { + return renderUserMessage(content) + } + return renderAssistantMarkdown(content) +} diff --git a/strix/interface/tui/internal/render/code.go b/strix/interface/tui/internal/render/code.go new file mode 100644 index 00000000..0e3383ec --- /dev/null +++ b/strix/interface/tui/internal/render/code.go @@ -0,0 +1,70 @@ +package render + +import ( + "path/filepath" + "strings" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" +) + +// HighlightCode ports the Python renderers' pygments highlighting: colorize +// code for the terminal using the "native" style, falling back to the plain +// text when the language is unknown or the highlighter fails. +func HighlightCode(code, language string) string { + if strings.TrimSpace(code) == "" { + return code + } + var lexer chroma.Lexer + if language != "" { + lexer = lexers.Get(language) + } + if lexer == nil { + lexer = lexers.Analyse(code) + } + if lexer == nil { + return Col(Text).Render(code) + } + lexer = chroma.Coalesce(lexer) + style := styles.Get("native") + formatter := formatters.Get("terminal256") + iterator, err := lexer.Tokenise(nil, code) + if err != nil { + return Col(Text).Render(code) + } + var out strings.Builder + if err := formatter.Format(&out, style, iterator); err != nil { + return Col(Text).Render(code) + } + return strings.TrimSuffix(out.String(), "\n") +} + +// languageForPath resolves a chroma language name from a file path, returning +// "" when the extension is unknown. +func languageForPath(path string) string { + if path == "" { + return "" + } + lexer := lexers.Match(filepath.Base(path)) + if lexer == nil { + return "" + } + return lexer.Config().Name +} + +// ParseFencedCode ports parse_fenced_code: strip a surrounding ``` fence and +// return the declared language (if any) and the inner code. +func ParseFencedCode(raw string) (language, code string) { + trimmed := strings.TrimSpace(raw) + if !strings.HasPrefix(trimmed, "```") { + return "", raw + } + lines := strings.Split(trimmed, "\n") + if len(lines) < 2 || strings.TrimSpace(lines[len(lines)-1]) != "```" { + return "", raw + } + language = strings.TrimSpace(strings.TrimPrefix(lines[0], "```")) + return language, strings.Join(lines[1:len(lines)-1], "\n") +} diff --git a/strix/interface/tui/internal/render/dependency.go b/strix/interface/tui/internal/render/dependency.go new file mode 100644 index 00000000..78bc6cd4 --- /dev/null +++ b/strix/interface/tui/internal/render/dependency.go @@ -0,0 +1,99 @@ +package render + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func renderDependencyReport(args map[string]any, result any) string { + resultMap, _ := result.(map[string]any) + // Unsuccessful / not-persisted variants. + if resultMap != nil { + success, hasSuccess := resultMap["success"].(bool) + warning := StringValue(resultMap["warning"]) + if (hasSuccess && !success) || warning != "" { + return renderDependencyUnsuccessful(args, resultMap) + } + } + var b strings.Builder + b.WriteString("πŸ“¦ " + Bold(ReportHdr).Render("Dependency (SCA) Report")) + field := func(label, value string) { + if value != "" { + b.WriteString("\n\n" + Bold(Field).Render(label+": ") + value) + } + } + title := StringValue(args["title"]) + field("Title", title) + if sev := StringValue(resultMap["severity"]); sev != "" { + b.WriteString("\n\n" + Bold(Field).Render("Severity: ") + + lipgloss.NewStyle().Bold(true).Foreground(SeverityColor(sev)).Render(strings.ToUpper(sev))) + } + if score, ok := NumericValue(args["advisory_cvss"]); ok { + b.WriteString("\n\n" + Bold(Field).Render("Advisory CVSS: ") + + lipgloss.NewStyle().Bold(true).Foreground(CVSSColor(score)).Render(StringValue(args["advisory_cvss"]))) + } + field("CVE", StringValue(args["cve"])) + field("CWE", StringValue(args["cwe"])) + if pkg := StringValue(args["package_name"]); pkg != "" { + b.WriteString("\n\n" + Bold(Field).Render("Package: ") + Bold(InfoBlue).Render(pkg)) + if eco := StringValue(args["package_ecosystem"]); eco != "" { + b.WriteString(Dim().Render(" (" + eco + ")")) + } + } + if inst := StringValue(args["installed_version"]); inst != "" { + b.WriteString("\n\n" + Bold(Field).Render("Installed: ") + Col(Red).Render(inst)) + if fixed := StringValue(args["fixed_version"]); fixed != "" { + b.WriteString(Dim().Render(" β†’ ") + Bold(Field).Render("Fixed: ") + Col(Green).Render(fixed)) + } + } + field("Fix Effort", StringValue(args["fix_effort"])) + field("Target", StringValue(args["target"])) + section := func(label, value string) { + if value != "" { + b.WriteString("\n\n" + Bold(Field).Render(label) + "\n" + value) + } + } + section("Description", StringValue(args["description"])) + section("Impact", StringValue(args["impact"])) + section("Technical Analysis", StringValue(args["technical_analysis"])) + section("Assumptions", StringValue(args["assumptions"])) + section("Remediation", StringValue(args["remediation_steps"])) + if title == "" { + b.WriteString("\n " + Dim().Render("Creating dependency report...")) + } + return "\n\n" + b.String() + "\n\n" +} + +func renderDependencyUnsuccessful(args, result map[string]any) string { + var b strings.Builder + b.WriteString("πŸ“¦ " + Bold(ReportHdr).Render("Dependency (SCA) Report")) + if title := StringValue(args["title"]); title != "" { + b.WriteString("\n\n" + Bold(Field).Render("Title: ") + title) + } + success, hasSuccess := result["success"].(bool) + var label, detail string + var style lipgloss.Style + if hasSuccess && !success { + detail = StringValue(result["error"]) + if errs, ok := result["errors"].([]any); ok && len(errs) > 0 { + var parts []string + for _, e := range errs { + parts = append(parts, StringValue(e)) + } + detail = strings.Join(parts, "; ") + } + label, style = "βœ— Not created: ", Bold(SevCrit) + if detail == "" { + detail = "Report was not created." + } + } else { + detail = StringValue(result["warning"]) + label, style = "⚠ Not persisted: ", Bold(SevMed) + if detail == "" { + detail = "Report could not be persisted." + } + } + b.WriteString("\n\n" + style.Render(label) + detail) + return "\n\n" + b.String() + "\n\n" +} diff --git a/strix/interface/tui/internal/render/file_edit.go b/strix/interface/tui/internal/render/file_edit.go new file mode 100644 index 00000000..e1c2176f --- /dev/null +++ b/strix/interface/tui/internal/render/file_edit.go @@ -0,0 +1,143 @@ +package render + +import ( + "strings" +) + +// --------------------------------------------------------------------------- +// Filesystem: apply_patch + view_image (filesystem_renderer.py) +// --------------------------------------------------------------------------- + +const ( + addFilePfx = "*** Add File: " + deleteFilePfx = "*** Delete File: " + updateFilePfx = "*** Update File: " + beginPatch = "*** Begin Patch" + endPatch = "*** End Patch" +) + +type patchOp struct { + kind string + path string + old []string + new []string +} + +func extractPatchText(args map[string]any) string { + if raw, ok := args["patch"].(string); ok { + return raw + } + if raw, ok := args["patch"].(map[string]any); ok { + if inner, ok := raw["patch"].(string); ok { + return inner + } + } + if fb, ok := args["input"].(string); ok { + return fb + } + return "" +} + +func parsePatchOperations(patch string) []patchOp { + var ops []patchOp + var cur *patchOp + flush := func() { + if cur != nil && cur.kind != "" { + ops = append(ops, *cur) + } + cur = nil + } + for _, line := range strings.Split(patch, "\n") { + switch { + case line == beginPatch || line == endPatch: + continue + case strings.HasPrefix(line, addFilePfx): + flush() + cur = &patchOp{kind: "add", path: strings.TrimSpace(line[len(addFilePfx):])} + case strings.HasPrefix(line, updateFilePfx): + flush() + cur = &patchOp{kind: "update", path: strings.TrimSpace(line[len(updateFilePfx):])} + case strings.HasPrefix(line, deleteFilePfx): + flush() + cur = &patchOp{kind: "delete", path: strings.TrimSpace(line[len(deleteFilePfx):])} + case cur != nil && cur.kind == "update": + if strings.HasPrefix(line, "@@") { + continue + } + if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") { + cur.old = append(cur.old, line[1:]) + } else if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { + cur.new = append(cur.new, line[1:]) + } + case cur != nil && cur.kind == "add": + if strings.HasPrefix(line, "+") { + cur.new = append(cur.new, line[1:]) + } else if strings.TrimSpace(line) != "" { + cur.new = append(cur.new, line) + } + } + } + flush() + return ops +} + +var opLabel = map[string]string{"add": "create", "update": "edit", "delete": "delete"} + +func renderPatchOperation(b *strings.Builder, op patchOp) { + label := opLabel[op.kind] + if label == "" { + label = "file" + } + b.WriteString(Col(Emerald).Render("β—‡ ") + Dim().Render(label)) + if op.path != "" { + p := op.path + if len(p) > 60 { + p = p[len(p)-60:] + } + b.WriteString(" " + Dim().Render(p)) + } + lang := languageForPath(op.path) + if op.kind == "update" { + for _, line := range highlightLines(op.old, lang) { + b.WriteString("\n" + Col(Red).Render("-") + " " + line) + } + for _, line := range highlightLines(op.new, lang) { + b.WriteString("\n" + Col(Green).Render("+") + " " + line) + } + } else if op.kind == "add" && len(op.new) > 0 { + b.WriteString("\n" + HighlightCode(strings.Join(op.new, "\n"), lang)) + } +} + +func highlightLines(lines []string, lang string) []string { + if len(lines) == 0 || lang == "" { + return lines + } + return strings.Split(HighlightCode(strings.Join(lines, "\n"), lang), "\n") +} + +func renderApplyPatch(args map[string]any, result any, status string) string { + ops := parsePatchOperations(extractPatchText(args)) + var b strings.Builder + if len(ops) == 0 { + b.WriteString(Col(Emerald).Render("β—‡ ") + Dim().Render("patch")) + if s, ok := result.(string); ok && strings.TrimSpace(s) != "" { + b.WriteString("\n " + Dim().Render(strings.TrimSpace(s))) + } else if result == nil { + b.WriteString(" " + Dim().Render("Processing...")) + } + return b.String() + } + for i, op := range ops { + if i > 0 { + b.WriteString("\n") + } + renderPatchOperation(&b, op) + } + if status == "failed" { + if s, ok := result.(string); ok && strings.TrimSpace(s) != "" { + b.WriteString("\n " + Col(Red).Render(strings.TrimSpace(s))) + } + } + return b.String() +} diff --git a/strix/interface/tui/internal/render/helpers.go b/strix/interface/tui/internal/render/helpers.go new file mode 100644 index 00000000..80c6dfa8 --- /dev/null +++ b/strix/interface/tui/internal/render/helpers.go @@ -0,0 +1,112 @@ +package render + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// --------------------------------------------------------------------------- +// small helpers +// --------------------------------------------------------------------------- + +func truthy(v any) bool { + switch x := v.(type) { + case bool: + return x + case string: + return x != "" + case float64: + return x != 0 + case nil: + return false + } + return v != nil +} + +func NumericValue(v any) (float64, bool) { + switch x := v.(type) { + case float64: + return x, true + case int: + return float64(x), true + case int64: + return float64(x), true + } + return 0, false +} + +func truncStr(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} + +func lastN(s string, n int) string { + if len(s) > n { + return s[len(s)-n:] + } + return s +} + +func firstN(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} + +func joinTrunc(items []any, max, limit int) string { + shown := items + if len(shown) > limit { + shown = shown[:limit] + } + var parts []string + for _, it := range shown { + parts = append(parts, ptrunc(StringValue(it), max)) + } + return strings.Join(parts, ", ") +} + +// stripControlsKeepTabs drops control bytes except \t and \n (shell cleaning). +func stripControlsKeepTabs(s string) string { + return strings.Map(func(r rune) rune { + if r == '\n' || r == '\t' || r >= 32 { + return r + } + return -1 + }, s) +} + +func StringValue(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return text + } + raw, err := json.Marshal(value) + if err == nil { + return string(raw) + } + return fmt.Sprint(value) +} +func StripControls(value string) string { + return strings.Map(func(r rune) rune { + if r == '\n' || r == '\t' || r >= 32 { + return r + } + return -1 + }, value) +} + +func SortedKeys(values map[string]any) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/strix/interface/tui/internal/render/image.go b/strix/interface/tui/internal/render/image.go new file mode 100644 index 00000000..9e6f118e --- /dev/null +++ b/strix/interface/tui/internal/render/image.go @@ -0,0 +1,106 @@ +package render + +import ( + "strings" +) + +func renderViewImage(args map[string]any, result any) string { + path := strings.TrimSpace(StringValue(args["path"])) + var b strings.Builder + b.WriteString(Col(Emerald).Render("β—‡ ") + Dim().Render("view image")) + if path != "" { + if len(path) > 60 { + path = path[len(path)-60:] + } + b.WriteString(" " + Dim().Render(path)) + } + if s, ok := result.(string); ok { + low := strings.ToLower(strings.TrimSpace(s)) + if strings.HasPrefix(low, "image path ") || strings.HasPrefix(low, "unable to read image") || + strings.HasPrefix(low, "manifest path") || strings.HasPrefix(low, "exceeded the allowed size") || + strings.Contains(low, "not a supported image") { + b.WriteString("\n " + Col(Red).Render(strings.TrimSpace(s))) + return b.String() + } + } + if isImageSuccess(result) { + b.WriteString(" " + Col(Green).Render("βœ“")) + if KittyGraphicsSupported() { + if mime, payload := extractImageDataURI(result); mime != "" { + if block := kittyImageBlock(mime, payload); block != "" { + b.WriteString("\n" + block) + } + } + } + } + return b.String() +} + +var imageMimes = []string{"png", "jpeg", "jpg", "gif", "webp"} + +func isBase64Byte(b byte) bool { + return b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b >= '0' && b <= '9' || + b == '+' || b == '/' || b == '=' +} + +// parseImageDataURI scans a data URI without a regexp: payloads run to +// megabytes and the regexp engine is far too slow to walk them per frame. +func parseImageDataURI(s string) (mime, payload string) { + start := strings.Index(s, "data:image/") + if start < 0 { + return "", "" + } + rest := s[start+len("data:image/"):] + for _, candidate := range imageMimes { + if !strings.HasPrefix(rest, candidate+";base64,") { + continue + } + data := rest[len(candidate)+len(";base64,"):] + end := len(data) + for i := range len(data) { + if !isBase64Byte(data[i]) { + end = i + break + } + } + if candidate == "jpg" { + candidate = "jpeg" + } + return candidate, data[:end] + } + return "", "" +} + +// extractImageDataURI pulls a base64 image payload out of a view_image tool +// result: a raw data URI or a structured map with an image_url/url field. +func extractImageDataURI(result any) (mime, payload string) { + var s string + switch v := result.(type) { + case string: + s = v + case map[string]any: + if u := StringValue(v["image_url"]); u != "" { + s = u + } else if u := StringValue(v["url"]); u != "" { + s = u + } + } + if s == "" { + return "", "" + } + mime, payload = parseImageDataURI(s) + if mime == "" || len(payload) < 100 || len(payload)%4 != 0 { + return "", "" + } + return mime, payload +} + +func isImageSuccess(result any) bool { + if m, ok := result.(map[string]any); ok { + return StringValue(m["type"]) == "image" + } + if s, ok := result.(string); ok { + return strings.HasPrefix(strings.TrimLeft(s, " \t\n"), "data:image/") + } + return false +} diff --git a/strix/interface/tui/internal/render/image_detect.go b/strix/interface/tui/internal/render/image_detect.go new file mode 100644 index 00000000..d1905208 --- /dev/null +++ b/strix/interface/tui/internal/render/image_detect.go @@ -0,0 +1,139 @@ +package render + +import ( + "bytes" + "os" + "time" + + "github.com/charmbracelet/x/term" +) + +// queryBudget bounds the whole capability exchange. A terminal answers in +// microseconds; anything this slow is not going to answer at all. +const queryBudget = 500 * time.Millisecond + +// drainBudget is the grace period spent collecting whatever else the terminal +// sent after the answer we were looking for. +const drainBudget = 50 * time.Millisecond + +// etx is what ctrl-c delivers while ISIG is cleared. +const etx = 0x03 + +// DetectKittyGraphics asks the terminal whether it supports the kitty +// graphics protocol, the way kitty's own tooling does: send a 1x1 query +// (a=q) followed by a Primary Device Attributes request, then read until the +// DA1 response arrives. A graphics-capable terminal answers the query with an +// APC "OK" response before the DA1; anything else ignores it. Must run before +// Bubble Tea takes over stdin. +func DetectKittyGraphics() { + supported, interrupted := queryKittyGraphics(os.Stdin, os.Stdout) + KittyGraphicsSupported = func() bool { return supported } + if interrupted { + // The query runs with ISIG cleared, so ctrl-c arrives as a byte instead + // of a signal. Raise it now that the terminal is restored, so a ctrl-c + // during startup quits rather than being swallowed. + interruptSelf() + } +} + +func queryKittyGraphics(in, out *os.File) (supported, interrupted bool) { + fd := int(in.Fd()) + if !term.IsTerminal(uintptr(fd)) { + return false, false + } + oldState, err := term.MakeRaw(uintptr(fd)) + if err != nil { + return false, false + } + // Everything the terminal sends must be consumed before the terminal echoes + // it: once cooked mode is back, a reply still in flight is printed to the + // screen as mojibake like "^[[?62;52;c". + defer term.Restore(uintptr(fd), oldState) //nolint:errcheck + + // The same 1x1 RGB query used by viuer and yazi; DA1 (CSI c) is answered + // by every terminal and bounds the read. + if _, err := out.WriteString("\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c"); err != nil { + return false, false + } + + reply := readCapabilityReply(in, queryBudget) + if reply.answered { + // The kitty answer arrives before the DA1, so the DA1 is still on its + // way. Take it now rather than leaving it for the shell to echo. + drainInput(in, drainBudget) + } + return reply.supported, reply.interrupted +} + +// capabilityReply is what the terminal told us: whether it supports the +// protocol, whether it answered at all, and whether the user pressed ctrl-c +// while we were waiting. +type capabilityReply struct { + supported bool + answered bool + interrupted bool +} + +// readCapabilityReply reads until the kitty answer or the DA1 that follows it, +// whichever comes first. +func readCapabilityReply(in *os.File, budget time.Duration) capabilityReply { + deadline := time.Now().Add(budget) + var buf bytes.Buffer + chunk := make([]byte, 256) + for { + remaining := time.Until(deadline) + if remaining <= 0 { + return capabilityReply{} + } + // The read itself has to be bounded. os.File deadlines do not work on a + // terminal - the fd is blocking, so it is never registered with the + // runtime poller and SetReadDeadline fails with "file type does not + // support deadline" - which would leave this read hanging until the + // terminal happened to send something. + ready, err := waitReadable(in, remaining) + if err != nil || !ready { + return capabilityReply{} + } + n, err := in.Read(chunk) + if n > 0 { + buf.Write(chunk[:n]) + // ctrl-c is ETX here rather than a signal. Stop waiting on the + // terminal the moment the user asks to leave. + if bytes.IndexByte(buf.Bytes(), etx) >= 0 { + return capabilityReply{interrupted: true} + } + if apc := bytes.Index(buf.Bytes(), []byte("\x1b_G")); apc >= 0 && + bytes.Contains(buf.Bytes()[apc:], []byte(";OK")) { + return capabilityReply{supported: true, answered: true} + } + // DA1 response: ESC [ ? ... c + if idx := bytes.Index(buf.Bytes(), []byte("\x1b[?")); idx >= 0 && + bytes.IndexByte(buf.Bytes()[idx:], 'c') >= 0 { + return capabilityReply{answered: true} + } + } + if err != nil { + return capabilityReply{} + } + } +} + +// drainInput consumes whatever is already readable, so no part of the terminal's +// answer survives into cooked mode. +func drainInput(in *os.File, budget time.Duration) { + deadline := time.Now().Add(budget) + chunk := make([]byte, 256) + for { + remaining := time.Until(deadline) + if remaining <= 0 { + return + } + ready, err := waitReadable(in, remaining) + if err != nil || !ready { + return + } + if _, err := in.Read(chunk); err != nil { + return + } + } +} diff --git a/strix/interface/tui/internal/render/image_detect_test.go b/strix/interface/tui/internal/render/image_detect_test.go new file mode 100644 index 00000000..41bde37e --- /dev/null +++ b/strix/interface/tui/internal/render/image_detect_test.go @@ -0,0 +1,133 @@ +//go:build !windows + +package render + +import ( + "os" + "testing" + "time" +) + +// A terminal that ignores the query must not stall startup. This is the bound +// that os.File read deadlines could not provide: a tty descriptor is blocking, +// so it is never registered with the runtime poller and SetReadDeadline fails +// with "file type does not support deadline", leaving the read to hang until the +// terminal happened to send something. +func TestCapabilityReadGivesUpOnASilentTerminal(t *testing.T) { + reader, writer := pipePair(t) + defer writer.Close() + + start := time.Now() + reply := readCapabilityReply(reader, 150*time.Millisecond) + + if reply.supported || reply.answered { + t.Fatalf("silence reported an answer: %+v", reply) + } + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Fatalf("the read was not bounded: %s", elapsed) + } +} + +func TestCapabilityReadClassifiesTheReply(t *testing.T) { + for _, testCase := range []struct { + name string + reply string + want bool + }{ + {"DA1 alone means no kitty support", "\x1b[?62;52;c", false}, + {"a kitty answer means support", "\x1b_Gi=31;OK\x1b\\\x1b[?62;52;c", true}, + } { + t.Run(testCase.name, func(t *testing.T) { + reader, writer := pipePair(t) + defer writer.Close() + if _, err := writer.WriteString(testCase.reply); err != nil { + t.Fatalf("write reply: %v", err) + } + + reply := readCapabilityReply(reader, time.Second) + + if !reply.answered { + t.Fatal("a reply was sent but not seen") + } + if reply.supported != testCase.want { + t.Fatalf("support = %v, want %v", reply.supported, testCase.want) + } + }) + } +} + +// The DA1 trails a kitty answer, so it is still arriving when the answer is +// recognized. Anything left unread is echoed to the screen once cooked mode +// returns, which is where "^[[?62;52;c" came from. +func TestDrainClearsWhatFollowsTheAnswer(t *testing.T) { + reader, writer := pipePair(t) + defer writer.Close() + if _, err := writer.WriteString("\x1b_Gi=31;OK\x1b\\\x1b[?62;52;c"); err != nil { + t.Fatalf("write reply: %v", err) + } + + reply := readCapabilityReply(reader, time.Second) + if !reply.supported || !reply.answered { + t.Fatalf("kitty answer not recognized: %+v", reply) + } + drainInput(reader, drainBudget) + + leftover, err := waitReadable(reader, 100*time.Millisecond) + if err != nil { + t.Fatalf("leftover check failed: %v", err) + } + if leftover { + t.Fatal("part of the reply survived the drain and would be echoed") + } +} + +// The query clears ISIG, so ctrl-c arrives as ETX rather than a signal. It has to +// end the wait instead of being swallowed as terminal noise, which is what left a +// hung startup unresponsive to ctrl-c. +func TestCtrlCEndsTheWait(t *testing.T) { + reader, writer := pipePair(t) + defer writer.Close() + if _, err := writer.Write([]byte{etx}); err != nil { + t.Fatalf("write ctrl-c: %v", err) + } + + start := time.Now() + reply := readCapabilityReply(reader, 10*time.Second) + + if !reply.interrupted { + t.Fatalf("ctrl-c was not recognized: %+v", reply) + } + if reply.answered || reply.supported { + t.Fatalf("ctrl-c must not be read as a terminal answer: %+v", reply) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("ctrl-c did not end the wait promptly: %s", elapsed) + } +} + +// waitReadable must report readiness without waiting out the whole timeout. +func TestWaitReadableSeesAvailableInput(t *testing.T) { + reader, writer := pipePair(t) + defer writer.Close() + if _, err := writer.WriteString("x"); err != nil { + t.Fatalf("write: %v", err) + } + + ready, err := waitReadable(reader, time.Second) + if err != nil { + t.Fatalf("waitReadable failed: %v", err) + } + if !ready { + t.Fatal("input was available but waitReadable reported none") + } +} + +func pipePair(t *testing.T) (reader, writer *os.File) { + t.Helper() + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + t.Cleanup(func() { reader.Close() }) + return reader, writer +} diff --git a/strix/interface/tui/internal/render/image_detect_unix.go b/strix/interface/tui/internal/render/image_detect_unix.go new file mode 100644 index 00000000..87b0b81a --- /dev/null +++ b/strix/interface/tui/internal/render/image_detect_unix.go @@ -0,0 +1,37 @@ +//go:build !windows + +package render + +import ( + "os" + "time" + + "golang.org/x/sys/unix" +) + +// waitReadable reports whether the descriptor has input available within the +// timeout. poll(2) works on a blocking terminal descriptor, which is what a tty +// is and why os.File read deadlines cannot be used here. +func waitReadable(in *os.File, timeout time.Duration) (bool, error) { + fds := []unix.PollFd{{Fd: int32(in.Fd()), Events: unix.POLLIN}} + milliseconds := int(timeout.Milliseconds()) + if milliseconds <= 0 { + milliseconds = 1 + } + for { + n, err := unix.Poll(fds, milliseconds) + if err == unix.EINTR { + continue + } + if err != nil { + return false, err + } + return n > 0, nil + } +} + +// interruptSelf raises the interrupt the terminal could not deliver while the +// capability query held the terminal with signals disabled. +func interruptSelf() { + _ = unix.Kill(os.Getpid(), unix.SIGINT) +} diff --git a/strix/interface/tui/internal/render/image_detect_windows.go b/strix/interface/tui/internal/render/image_detect_windows.go new file mode 100644 index 00000000..7825f609 --- /dev/null +++ b/strix/interface/tui/internal/render/image_detect_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +package render + +import ( + "os" + "time" +) + +// waitReadable has no console equivalent worth carrying: no Windows terminal +// implements the kitty graphics protocol, so detection reports no support rather +// than blocking on a reply that never comes. +func waitReadable(_ *os.File, _ time.Duration) (bool, error) { + return false, nil +} + +// interruptSelf has nothing to do: detection never reads on this platform, so +// ctrl-c is never withheld from the console. +func interruptSelf() {} diff --git a/strix/interface/tui/internal/render/image_kitty.go b/strix/interface/tui/internal/render/image_kitty.go new file mode 100644 index 00000000..1eeb006e --- /dev/null +++ b/strix/interface/tui/internal/render/image_kitty.go @@ -0,0 +1,202 @@ +package render + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + "image/png" + "strings" + "sync" +) + +// Native inline images via the kitty graphics protocol with Unicode +// placeholders (https://sw.kovidgoyal.net/kitty/graphics-protocol/): the image +// is transmitted once out of band with a virtual placement, and the chat trace +// renders placeholder cells that the terminal replaces with real pixels. The +// placeholder rows are plain styled text, so they scroll and diff like any +// other Bubble Tea content. Terminals without the protocol show no preview. + +const ( + imageMinCols = 20 + imageMaxCols = 100 + imageDefaultCols = 72 + imageMaxRows = 28 + kittyChunkSize = 4096 +) + +var imageCols = imageDefaultCols + +// SetImageWidth sizes inline image placements to the chat content width in cells. +func SetImageWidth(cells int) { + imageCols = min(max(cells, imageMinCols), imageMaxCols) +} + +// KittyGraphicsSupported reports whether the terminal supports the kitty +// graphics protocol; set at startup by DetectKittyGraphics via a live +// terminal query. +var KittyGraphicsSupported = func() bool { return false } + +type kittyPlacement struct { + id uint32 + cols int + rows int + placeholder string +} + +var ( + kittyMu sync.Mutex + kittyByHash = map[string]kittyPlacement{} + kittyQueue []string + kittyNextID uint32 = 1 +) + +// DrainImageTransmissions returns queued kitty transmit/placement sequences, +// to be written directly to the terminal exactly once per image. +func DrainImageTransmissions() []string { + kittyMu.Lock() + defer kittyMu.Unlock() + out := kittyQueue + kittyQueue = nil + return out +} + +// payloadKey identifies an image payload without hashing megabytes of base64 +// on every frame: its length plus both ends are enough to tell distinct +// images apart. +func payloadKey(payload string) string { + const edge = 64 + if len(payload) <= 2*edge { + return payload + } + return fmt.Sprintf("%d:%s:%s", len(payload), payload[:edge], payload[len(payload)-edge:]) +} + +// kittyImageBlock registers the image payload (queueing its transmission on +// first sight) and returns the styled placeholder block for the chat trace. +func kittyImageBlock(mime, payload string) string { + kittyMu.Lock() + defer kittyMu.Unlock() + key := payloadKey(payload) + placement, ok := kittyByHash[key] + if !ok { + pngData, w, h := payloadToPNG(mime, payload) + if pngData == nil { + return "" + } + cols := min(imageCols, w) + rows := (h*cols + w - 1) / (w * 2) + rows = min(max(1, rows), imageMaxRows) + placement = kittyPlacement{id: kittyNextID, cols: cols, rows: rows} + placement.placeholder = kittyPlaceholder(placement) + kittyNextID++ + kittyByHash[key] = placement + kittyQueue = append(kittyQueue, kittyTransmit(placement, pngData)) + } + return placement.placeholder +} + +func payloadToPNG(mime, payload string) (data []byte, w, h int) { + raw, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return nil, 0, 0 + } + img, _, err := image.Decode(bytes.NewReader(raw)) + if err != nil { + return nil, 0, 0 + } + bounds := img.Bounds() + if bounds.Dx() <= 0 || bounds.Dy() <= 0 { + return nil, 0, 0 + } + if mime == "png" { + return raw, bounds.Dx(), bounds.Dy() + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, 0, 0 + } + return buf.Bytes(), bounds.Dx(), bounds.Dy() +} + +// kittyTransmit builds the chunked APC sequences transmitting the PNG and +// creating a virtual (U=1) placement for Unicode placeholders. +func kittyTransmit(p kittyPlacement, pngData []byte) string { + encoded := base64.StdEncoding.EncodeToString(pngData) + var b strings.Builder + first := true + for len(encoded) > 0 { + chunk := encoded + if len(chunk) > kittyChunkSize { + chunk = chunk[:kittyChunkSize] + } + encoded = encoded[len(chunk):] + more := 0 + if len(encoded) > 0 { + more = 1 + } + if first { + fmt.Fprintf(&b, "\x1b_Ga=t,q=2,f=100,i=%d,m=%d;%s\x1b\\", p.id, more, chunk) + first = false + } else { + fmt.Fprintf(&b, "\x1b_Gm=%d;%s\x1b\\", more, chunk) + } + } + fmt.Fprintf(&b, "\x1b_Ga=p,q=2,U=1,i=%d,c=%d,r=%d\x1b\\", p.id, p.cols, p.rows) + return b.String() +} + +// kittyPlaceholder renders the rows x cols grid of U+10EEEE placeholder cells +// carrying the image id in the foreground color and the cell position in +// row/column diacritics. The id must reach the terminal as an exact truecolor +// value, so the SGR sequence is emitted directly rather than through lipgloss +// (whose profile detection may downsample it). +func kittyPlaceholder(p kittyPlacement) string { + id := p.id & 0xffffff + var b strings.Builder + for row := range p.rows { + if row > 0 { + b.WriteString("\n") + } + fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm", id>>16&0xff, id>>8&0xff, id&0xff) + for col := range p.cols { + b.WriteRune(0x10eeee) + b.WriteRune(rowColumnDiacritics[row]) + b.WriteRune(rowColumnDiacritics[col]) + } + b.WriteString("\x1b[39m") + } + return b.String() +} + +// rowColumnDiacritics is kitty's canonical placeholder diacritic table +// (gen/rowcolumn-diacritics.txt); index n encodes row/column number n. +var rowColumnDiacritics = []rune{ + 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, 0x034B, 0x034C, + 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369, + 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, + 0x0593, 0x0594, 0x0595, 0x0597, 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, + 0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615, + 0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8, + 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, 0x06E4, 0x06E7, 0x06E8, 0x06EB, + 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, + 0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, + 0x0816, 0x0817, 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822, + 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, 0x0953, 0x0954, + 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76, + 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, + 0x1B72, 0x1B73, 0x1CD0, 0x1CD1, 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, + 0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, + 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1, + 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, 0x20D4, 0x20D5, 0x20D6, 0x20D7, + 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, + 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, + 0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA, + 0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1, + 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, + 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, + 0xFE20, 0xFE21, 0xFE22, 0xFE23, 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, + 0x1D187, 0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244, +} diff --git a/strix/interface/tui/internal/render/image_kitty_test.go b/strix/interface/tui/internal/render/image_kitty_test.go new file mode 100644 index 00000000..0f838e59 --- /dev/null +++ b/strix/interface/tui/internal/render/image_kitty_test.go @@ -0,0 +1,103 @@ +package render + +import ( + "bytes" + "encoding/base64" + "image" + "image/color" + "image/png" + "strings" + "testing" +) + +func testImageDataURI(t *testing.T, w, h int) string { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + img.Set(x, y, color.RGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +func withKittySupport(t *testing.T, supported bool) { + t.Helper() + previous := KittyGraphicsSupported + KittyGraphicsSupported = func() bool { return supported } + t.Cleanup(func() { KittyGraphicsSupported = previous }) +} + +func TestViewImageRendersKittyPlaceholders(t *testing.T) { + withKittySupport(t, true) + uri := testImageDataURI(t, 120, 80) + out := Tool(tool("view_image", map[string]any{"path": "/tmp/shot.png"}, uri, "completed")) + if !strings.ContainsRune(out, 0x10eeee) { + t.Fatalf("expected kitty placeholder cells in render:\n%s", out) + } + + transmissions := DrainImageTransmissions() + if len(transmissions) != 1 { + t.Fatalf("expected one queued transmission, got %d", len(transmissions)) + } + seq := transmissions[0] + if !strings.Contains(seq, "\x1b_Ga=t,q=2,f=100,") { + t.Fatalf("missing transmit sequence: %.80s", seq) + } + if !strings.Contains(seq, "a=p,q=2,U=1,") { + t.Fatalf("missing virtual placement: %.80s", seq) + } + + // Re-rendering the same image must not queue a second transmission. + Tool(tool("view_image", map[string]any{"path": "/tmp/shot.png"}, uri, "completed")) + if again := DrainImageTransmissions(); len(again) != 0 { + t.Fatalf("image retransmitted: %d", len(again)) + } +} + +func TestViewImageWithoutKittySupportShowsNoPreview(t *testing.T) { + withKittySupport(t, false) + uri := testImageDataURI(t, 60, 40) + out := Tool(tool("view_image", map[string]any{"path": "/tmp/shot.png"}, uri, "completed")) + if !strings.Contains(out, "βœ“") { + t.Fatalf("expected success check:\n%s", out) + } + if strings.ContainsRune(out, 0x10eeee) { + t.Fatal("placeholder cells must not render without kitty graphics support") + } + if len(DrainImageTransmissions()) != 0 { + t.Fatal("no transmissions expected without kitty graphics support") + } +} + +func TestExtractImageDataURI(t *testing.T) { + uri := testImageDataURI(t, 8, 8) + if mime, payload := extractImageDataURI(uri); mime != "png" || payload == "" { + t.Fatal("raw data URI should extract") + } + if mime, _ := extractImageDataURI(map[string]any{"image_url": uri}); mime != "png" { + t.Fatal("structured result should extract") + } + if mime, _ := extractImageDataURI("data:image/png;base64,short"); mime != "" { + t.Fatal("tiny payload must be rejected") + } +} + +func TestKittyPlaceholderGrid(t *testing.T) { + p := kittyPlacement{id: 3, cols: 4, rows: 2} + out := kittyPlaceholder(p) + lines := strings.Split(out, "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 rows, got %d", len(lines)) + } + if got := strings.Count(out, string(rune(0x10eeee))); got != 8 { + t.Fatalf("expected 8 placeholder cells, got %d", got) + } + if !strings.Contains(out, "\x1b[38;2;0;0;3m") { + t.Fatalf("placeholder must carry the image id in the foreground color:\n%q", out) + } +} diff --git a/strix/interface/tui/internal/render/markdown_test.go b/strix/interface/tui/internal/render/markdown_test.go new file mode 100644 index 00000000..cd701e26 --- /dev/null +++ b/strix/interface/tui/internal/render/markdown_test.go @@ -0,0 +1,103 @@ +package render + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestHighlightCodeColorsKnownLanguage(t *testing.T) { + out := HighlightCode("def main():\n return 1", "python") + if !strings.Contains(out, "\x1b[") { + t.Fatal("python code was not colorized") + } + if ansi.Strip(out) != "def main():\n return 1" { + t.Fatalf("highlighting changed the code text: %q", ansi.Strip(out)) + } +} + +func TestMarkdownCodeFenceIsHighlighted(t *testing.T) { + out := renderAssistantMarkdown("intro\n```python\nimport os\n```\ndone") + plain := ansi.Strip(out) + if !strings.Contains(plain, "import os") { + t.Fatalf("code fence content missing: %q", plain) + } + if strings.Contains(plain, "```") { + t.Fatalf("fence markers leaked into output: %q", plain) + } +} + +func TestParseFencedCode(t *testing.T) { + lang, code := ParseFencedCode("```python\nprint(1)\n```") + if lang != "python" || code != "print(1)" { + t.Fatalf("got lang=%q code=%q", lang, code) + } + lang, code = ParseFencedCode("plain text") + if lang != "" || code != "plain text" { + t.Fatalf("unfenced text mangled: lang=%q code=%q", lang, code) + } +} + +func TestMarkdownTableIsAligned(t *testing.T) { + out := renderAssistantMarkdown(strings.Join([]string{ + "| Name | Severity |", + "| --- | --- |", + "| SQLi | **high** |", + "| XSS | low |", + }, "\n")) + plain := ansi.Strip(out) + lines := strings.Split(plain, "\n") + if len(lines) != 4 { + t.Fatalf("expected 4 table rows, got %d: %q", len(lines), plain) + } + if !strings.Contains(lines[0], "Name") || !strings.Contains(lines[0], "β”‚") { + t.Fatalf("header row not formatted: %q", lines[0]) + } + if !strings.Contains(lines[1], "─┼─") { + t.Fatalf("separator rule missing: %q", lines[1]) + } + if !strings.Contains(lines[2], "high") || strings.Contains(lines[2], "**") { + t.Fatalf("body cell not inline-formatted: %q", lines[2]) + } + if strings.Index(lines[2], "β”‚") != strings.Index(lines[3], "β”‚") { + t.Fatalf("columns misaligned:\n%q\n%q", lines[2], lines[3]) + } +} + +func TestNonTablePipeLinesAreLeftAlone(t *testing.T) { + out := renderAssistantMarkdown("a | b\nplain line") + if !strings.Contains(ansi.Strip(out), "a | b") { + t.Fatalf("pipe text mangled: %q", ansi.Strip(out)) + } +} + +func TestInlineFormatKeepsNonEmphasisMarkers(t *testing.T) { + literal := []string{ + "ls *.py *.go", + "snake_case_name and other_var_here", + "a * b * c", + "call obj.__init__ now", + "rm -rf /tmp/* /var/*", + "5 * 3 = 15", + } + for _, line := range literal { + if got := ansi.Strip(inlineFormat(line)); got != line { + t.Fatalf("%q was treated as emphasis: %q", line, got) + } + } +} + +func TestInlineFormatStillStylesRealEmphasis(t *testing.T) { + cases := map[string]string{ + "this is *italic* text": "this is italic text", + "this is **bold** text": "this is bold text", + "gone ~~away~~ now": "gone away now", + "use `code` here": "use code here", + } + for line, want := range cases { + if got := ansi.Strip(inlineFormat(line)); got != want { + t.Fatalf("%q: got %q want %q", line, got, want) + } + } +} diff --git a/strix/interface/tui/internal/render/notes.go b/strix/interface/tui/internal/render/notes.go new file mode 100644 index 00000000..6c81492e --- /dev/null +++ b/strix/interface/tui/internal/render/notes.go @@ -0,0 +1,111 @@ +package render + +import ( + "strings" +) + +// --------------------------------------------------------------------------- +// Notes (notes_renderer.py) +// --------------------------------------------------------------------------- + +func renderNote(name string, args map[string]any, result any) string { + var b strings.Builder + icon := Col(Gold).Render("β—‡ ") + switch name { + case "create_note": + category := StringValue(args["category"]) + if category == "" { + category = "general" + } + title, content := strings.TrimSpace(StringValue(args["title"])), strings.TrimSpace(StringValue(args["content"])) + b.WriteString(icon + Dim().Render("note") + " " + Dim().Render("("+category+")")) + if title != "" { + b.WriteString("\n " + title) + } + if content != "" { + b.WriteString("\n " + Dim().Render(content)) + } + if title == "" && content == "" { + b.WriteString("\n " + Dim().Render("Capturing...")) + } + case "delete_note": + b.WriteString(icon + Dim().Render("note removed")) + case "update_note": + title, content := StringValue(args["title"]), strings.TrimSpace(StringValue(args["content"])) + b.WriteString(icon + Dim().Render("note updated")) + if title != "" { + b.WriteString("\n " + title) + } + if content != "" { + b.WriteString("\n " + Dim().Render(content)) + } + if title == "" && content == "" { + b.WriteString("\n " + Dim().Render("Updating...")) + } + case "list_notes": + b.WriteString(icon + Dim().Render("notes")) + b.WriteString(noteListBody(result)) + case "get_note": + b.WriteString(icon + Dim().Render("note read")) + if m, ok := result.(map[string]any); ok && truthy(m["success"]) { + note, _ := m["note"].(map[string]any) + renderSingleNote(&b, note) + } else { + b.WriteString("\n " + Dim().Render("Loading...")) + } + default: + b.WriteString(icon + Dim().Render(strings.ReplaceAll(name, "_", " "))) + } + return b.String() +} + +func noteListBody(result any) string { + var b strings.Builder + if s, ok := result.(string); ok && strings.TrimSpace(s) != "" { + return "\n " + Dim().Render(strings.TrimSpace(s)) + } + m, ok := result.(map[string]any) + if !ok || !truthy(m["success"]) { + return "\n " + Dim().Render("Loading...") + } + notes, _ := m["notes"].([]any) + count, _ := NumericValue(m["total_count"]) + if int(count) == 0 || len(notes) == 0 { + return "\n " + Dim().Render("No notes") + } + for _, n := range notes { + note, _ := n.(map[string]any) + title := strings.TrimSpace(StringValue(note["title"])) + if title == "" { + title = "(untitled)" + } + category := StringValue(note["category"]) + if category == "" { + category = "general" + } + content := strings.TrimSpace(StringValue(note["content"])) + if content == "" { + content = strings.TrimSpace(StringValue(note["content_preview"])) + } + b.WriteString("\n - " + title + Dim().Render(" ("+category+")")) + if content != "" { + b.WriteString("\n " + Dim().Render(content)) + } + } + return b.String() +} + +func renderSingleNote(b *strings.Builder, note map[string]any) { + title := strings.TrimSpace(StringValue(note["title"])) + if title == "" { + title = "(untitled)" + } + category := StringValue(note["category"]) + if category == "" { + category = "general" + } + b.WriteString("\n " + title + Dim().Render(" ("+category+")")) + if content := strings.TrimSpace(StringValue(note["content"])); content != "" { + b.WriteString("\n " + Dim().Render(content)) + } +} diff --git a/strix/interface/tui/internal/render/proxy.go b/strix/interface/tui/internal/render/proxy.go new file mode 100644 index 00000000..a8f9fb75 --- /dev/null +++ b/strix/interface/tui/internal/render/proxy.go @@ -0,0 +1,577 @@ +package render + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// Proxy (proxy_renderer.py) +// --------------------------------------------------------------------------- + +const proxyIcon = "<~>" + +func proxyStatusStyle(code int) lipgloss.Style { + switch { + case code >= 200 && code < 300: + return Col(Green) + case code >= 300 && code < 400: + return Col(Status3xx) + case code >= 400 && code < 500: + return Col(Status4xx) + case code >= 500: + return Col(Red) + } + return Dim() +} + +func ptrunc(s string, max int) string { + if len(s) > max { + return s[:max-3] + "..." + } + return s +} + +func psanitize(s string, max int) string { + clean := strings.NewReplacer("\n", " ", "\r", "", "\t", " ").Replace(s) + return ptrunc(clean, max) +} + +func renderProxyTool(name string, args map[string]any, result any, status string) string { + switch name { + case "list_requests": + return renderListRequests(args, result, status) + case "view_request": + return renderViewRequest(args, result, status) + case "repeat_request": + return renderRepeatRequest(args, result, status) + case "list_sitemap": + return renderListSitemap(args, result, status) + case "view_sitemap_entry": + return renderViewSitemapEntry(args, result, status) + case "scope_rules": + return renderScopeRules(args, result, status) + } + return "" +} + +func resultMapOf(result any) (map[string]any, bool) { + m, ok := result.(map[string]any) + return m, ok +} + +func renderListRequests(args map[string]any, result any, status string) string { + var b strings.Builder + b.WriteString(Dim().Render(proxyIcon) + Col(Cyan).Render(" listing requests")) + if f := StringValue(args["httpql_filter"]); f != "" { + b.WriteString(Dim().Italic(true).Render(" where " + ptrunc(f, 150))) + } + var meta []string + if s := StringValue(args["sort_by"]); s != "" && s != "timestamp" { + meta = append(meta, "by:"+s) + } + if s := StringValue(args["sort_order"]); s != "" && s != "desc" { + meta = append(meta, s) + } + if s := StringValue(args["scope_id"]); s != "" { + meta = append(meta, "scope:"+truncStr(s, 8)) + } + if len(meta) > 0 { + b.WriteString(Dim().Render(" (" + strings.Join(meta, ", ") + ")")) + } + if status == "completed" { + if m, ok := resultMapOf(result); ok { + if e, has := m["error"]; has { + b.WriteString(Col(Red).Render(" error: " + psanitize(StringValue(e), 150))) + } else { + entries, _ := m["entries"].([]any) + suffix := "" + if pi, ok := m["page_info"].(map[string]any); ok && truthy(pi["has_next_page"]) { + suffix = "+" + } + b.WriteString(Dim().Render(fmt.Sprintf(" [%d%s found]", len(entries), suffix))) + renderRequestEntries(&b, entries) + } + } + } + return b.String() +} + +func renderRequestEntries(b *strings.Builder, entries []any) { + if len(entries) == 0 { + return + } + b.WriteString("\n") + limit := len(entries) + if limit > 20 { + limit = 20 + } + for i := 0; i < limit; i++ { + entry, ok := entries[i].(map[string]any) + if !ok { + continue + } + req, _ := entry["request"].(map[string]any) + resp, _ := entry["response"].(map[string]any) + method := StringValue(req["method"]) + if method == "" { + method = "?" + } + host := StringValue(req["host"]) + path := StringValue(req["path"]) + if path == "" { + path = "/" + } + b.WriteString(" " + Col(Lavender).Render(fmt.Sprintf("%-6s", method))) + b.WriteString(Dim().Render(" " + ptrunc(host+path, 180))) + if code, ok := NumericValue(resp["status_code"]); ok && code != 0 { + b.WriteString(proxyStatusStyle(int(code)).Render(fmt.Sprintf(" %d", int(code)))) + } + if i < limit-1 { + b.WriteString("\n") + } + } + if len(entries) > 20 { + b.WriteString("\n" + Dim().Italic(true).Render(fmt.Sprintf(" ... +%d more", len(entries)-20))) + } +} + +func renderViewRequest(args map[string]any, result any, status string) string { + var b strings.Builder + b.WriteString(Dim().Render(proxyIcon)) + part := StringValue(args["part"]) + if part == "" { + part = "request" + } + action := "viewing" + search := StringValue(args["search_pattern"]) + if search != "" { + action = "searching" + } + b.WriteString(Col(Cyan).Render(" " + action + " " + part)) + if rid := StringValue(args["request_id"]); rid != "" { + b.WriteString(Dim().Render(" #" + rid)) + } + if search != "" { + b.WriteString(Dim().Italic(true).Render(" /" + ptrunc(search, 100) + "/")) + } + if status == "completed" { + if m, ok := resultMapOf(result); ok { + if e, has := m["error"]; has { + b.WriteString(Col(Red).Render(" error: " + psanitize(StringValue(e), 150))) + } else if hits, has := m["hits"].([]any); has { + total := len(hits) + if t, ok := NumericValue(m["total_hits"]); ok { + total = int(t) + } + b.WriteString(Dim().Render(fmt.Sprintf(" [%d matches]", total))) + renderSearchHits(&b, hits) + } else if content, has := m["content"]; has { + page := 1 + if p, ok := NumericValue(m["page"]); ok { + page = int(p) + } + tl := 0 + if t, ok := NumericValue(m["total_lines"]); ok { + tl = int(t) + } + b.WriteString(Dim().Render(fmt.Sprintf(" [page %d, %d lines]", page, tl))) + renderContentLines(&b, StringValue(content), truthy(m["has_more"])) + } + } + } + return b.String() +} + +func renderSearchHits(b *strings.Builder, hits []any) { + if len(hits) == 0 { + return + } + b.WriteString("\n") + limit := len(hits) + if limit > 5 { + limit = 5 + } + for i := 0; i < limit; i++ { + m, ok := hits[i].(map[string]any) + if !ok { + continue + } + before := lastN(strings.NewReplacer("\n", " ", "\r", "").Replace(StringValue(m["before"])), 100) + after := firstN(strings.NewReplacer("\n", " ", "\r", "").Replace(StringValue(m["after"])), 100) + b.WriteString(" ") + if before != "" { + b.WriteString(Dim().Render("..." + before)) + } + b.WriteString(Bold(Green).Render(StringValue(m["match"]))) + if after != "" { + b.WriteString(Dim().Render(after + "...")) + } + if i < limit-1 { + b.WriteString("\n") + } + } + if len(hits) > 5 { + b.WriteString("\n" + Dim().Italic(true).Render(fmt.Sprintf(" ... +%d more matches", len(hits)-5))) + } +} + +func renderContentLines(b *strings.Builder, content string, hasMore bool) { + if content == "" { + return + } + allLines := strings.Split(content, "\n") + lines := allLines + if len(lines) > 15 { + lines = lines[:15] + } + b.WriteString("\n") + for i, line := range lines { + b.WriteString(" " + Dim().Render(ptrunc(line, maxLineLength))) + if i < len(lines)-1 { + b.WriteString("\n") + } + } + if hasMore || len(allLines) > 15 { + b.WriteString("\n" + Dim().Italic(true).Render(" ... more content available")) + } +} + +func renderRepeatRequest(args map[string]any, result any, status string) string { + var b strings.Builder + b.WriteString(Dim().Render(proxyIcon) + Col(Cyan).Render(" repeating request")) + if rid := StringValue(args["request_id"]); rid != "" { + b.WriteString(Dim().Render(" #" + rid)) + } + if mods, ok := args["modifications"].(map[string]any); ok { + b.WriteString(Dim().Italic(true).Render("\n modifications:")) + arrow := Col(Blue).Render(" >> ") + if url, ok := mods["url"]; ok { + b.WriteString("\n" + arrow + Dim().Render("url: "+ptrunc(StringValue(url), 180))) + } + writeKV := func(key, prefix string, valMax int) { + if kv, ok := mods[key].(map[string]any); ok { + n := 0 + for k, v := range kv { + if n >= 5 { + break + } + b.WriteString("\n" + arrow + Dim().Render(fmt.Sprintf(prefix, k, psanitize(StringValue(v), valMax)))) + n++ + } + } + } + writeKV("headers", "%s: %s", 150) + writeKV("cookies", "cookie %s=%s", 100) + writeKV("params", "param %s=%s", 100) + if body, ok := mods["body"].(string); ok { + b.WriteString("\n" + arrow) + bodyLines := strings.Split(body, "\n") + shown := bodyLines + if len(shown) > 4 { + shown = shown[:4] + } + for i, line := range shown { + if i > 0 { + b.WriteString("\n" + Dim().Render(" ")) + } + b.WriteString(Dim().Render(ptrunc(line, maxLineLength))) + } + if len(bodyLines) > 4 { + b.WriteString(Dim().Italic(true).Render(" ...")) + } + } + } else if mods, ok := args["modifications"].(string); ok && mods != "" { + b.WriteString(Dim().Italic(true).Render("\n " + ptrunc(mods, 200))) + } + if status == "completed" { + if m, ok := resultMapOf(result); ok { + success, hasSuccess := m["success"].(bool) + if hasSuccess && !success && StringValue(m["error"]) != "" { + b.WriteString(Col(Red).Render("\n error: " + psanitize(StringValue(m["error"]), 150))) + } else { + resp, _ := m["response"].(map[string]any) + b.WriteString("\n" + Col(Green).Render(" << ")) + if code, ok := NumericValue(resp["status_code"]); ok && code != 0 { + b.WriteString(proxyStatusStyle(int(code)).Render(fmt.Sprintf("%d", int(code)))) + } else { + b.WriteString(Dim().Render("(no response)")) + } + if ms, ok := NumericValue(m["elapsed_ms"]); ok && ms != 0 { + b.WriteString(Dim().Render(fmt.Sprintf(" (%dms)", int(ms)))) + } + body := StringValue(resp["body"]) + if body != "" { + allLines := strings.Split(body, "\n") + lines := allLines + if len(lines) > 5 { + lines = lines[:5] + } + for _, line := range lines { + b.WriteString("\n" + Col(Green).Render(" << ") + Dim().Render(ptrunc(line, maxLineLength-5))) + } + if truthy(resp["body_truncated"]) || len(allLines) > 5 { + b.WriteString("\n" + Dim().Italic(true).Render(" ...")) + } + } + } + } + } + return b.String() +} + +func renderListSitemap(args map[string]any, result any, status string) string { + var b strings.Builder + b.WriteString(Dim().Render(proxyIcon) + Col(Cyan).Render(" listing sitemap")) + if pid := StringValue(args["parent_id"]); pid != "" { + b.WriteString(Dim().Render(" under #" + ptrunc(pid, 20))) + } + var meta []string + if s := StringValue(args["scope_id"]); s != "" { + meta = append(meta, "scope:"+truncStr(s, 8)) + } + if d := StringValue(args["depth"]); d != "" && d != "DIRECT" { + meta = append(meta, strings.ToLower(d)) + } + if len(meta) > 0 { + b.WriteString(Dim().Render(" (" + strings.Join(meta, ", ") + ")")) + } + if status == "completed" { + if m, ok := resultMapOf(result); ok { + if e, has := m["error"]; has { + b.WriteString(Col(Red).Render(" error: " + psanitize(StringValue(e), 150))) + } else { + total := 0 + if t, ok := NumericValue(m["total_count"]); ok { + total = int(t) + } + entries, _ := m["entries"].([]any) + b.WriteString(Dim().Render(fmt.Sprintf(" [%d entries]", total))) + renderSitemapEntries(&b, entries) + } + } + } + return b.String() +} + +var sitemapKindColors = map[string]lipgloss.Color{ + "DOMAIN": AmberY, "DIRECTORY": Blue, "REQUEST": Green, +} + +func renderSitemapEntries(b *strings.Builder, entries []any) { + if len(entries) == 0 { + return + } + b.WriteString("\n") + limit := len(entries) + if limit > 20 { + limit = 20 + } + for i := 0; i < limit; i++ { + entry, ok := entries[i].(map[string]any) + if !ok { + continue + } + kind := StringValue(entry["kind"]) + if kind == "" { + kind = "?" + } + label := StringValue(entry["label"]) + if label == "" { + label = "?" + } + kindStyle, ok := sitemapKindColors[kind] + style := Dim() + if ok { + style = Col(kindStyle) + } + abbr := kind + if len(abbr) > 3 { + abbr = abbr[:3] + } + b.WriteString(" " + style.Render(fmt.Sprintf("%-3s", abbr)) + Dim().Render(" "+ptrunc(label, 150))) + if req, ok := entry["request"].(map[string]any); ok { + if method := StringValue(req["method"]); method != "" { + b.WriteString(Col(Lavender).Render(" " + method)) + } + if code, ok := NumericValue(req["status_code"]); ok && code != 0 { + b.WriteString(proxyStatusStyle(int(code)).Render(fmt.Sprintf(" %d", int(code)))) + } + } + if truthy(entry["has_descendants"]) { + b.WriteString(Dim().Italic(true).Render(" +")) + } + if i < limit-1 { + b.WriteString("\n") + } + } + if len(entries) > 20 { + b.WriteString("\n" + Dim().Italic(true).Render(fmt.Sprintf(" ... +%d more", len(entries)-20))) + } +} + +func renderViewSitemapEntry(args map[string]any, result any, status string) string { + var b strings.Builder + b.WriteString(Dim().Render(proxyIcon) + Col(Cyan).Render(" viewing sitemap")) + if eid := StringValue(args["entry_id"]); eid != "" { + b.WriteString(Dim().Render(" #" + ptrunc(eid, 20))) + } + if status == "completed" { + if m, ok := resultMapOf(result); ok { + if e, has := m["error"]; has { + b.WriteString(Col(Red).Render(" error: " + psanitize(StringValue(e), 150))) + } else if entry, ok := m["entry"].(map[string]any); ok { + kind, label := StringValue(entry["kind"]), StringValue(entry["label"]) + related, _ := entry["related_requests"].(map[string]any) + if kind != "" && label != "" { + b.WriteString(Dim().Render(fmt.Sprintf(" %s: %s", kind, ptrunc(label, 120)))) + } + total := 0 + if t, ok := NumericValue(related["total_count"]); ok { + total = int(t) + } + if total != 0 { + b.WriteString(Dim().Render(fmt.Sprintf(" [%d requests]", total))) + } + reqs, _ := related["requests"].([]any) + renderRelatedRequests(&b, reqs) + } + } + } + return b.String() +} + +func renderRelatedRequests(b *strings.Builder, reqs []any) { + if len(reqs) == 0 { + return + } + b.WriteString("\n") + limit := len(reqs) + if limit > 10 { + limit = 10 + } + for i := 0; i < limit; i++ { + req, ok := reqs[i].(map[string]any) + if !ok { + continue + } + method := StringValue(req["method"]) + if method == "" { + method = "?" + } + path := StringValue(req["path"]) + if path == "" { + path = "/" + } + b.WriteString(" " + Col(Lavender).Render(fmt.Sprintf("%-6s", method)) + Dim().Render(" "+ptrunc(path, 180))) + if code, ok := NumericValue(req["status_code"]); ok && code != 0 { + b.WriteString(proxyStatusStyle(int(code)).Render(fmt.Sprintf(" %d", int(code)))) + } + if i < limit-1 { + b.WriteString("\n") + } + } + if len(reqs) > 10 { + b.WriteString("\n" + Dim().Italic(true).Render(fmt.Sprintf(" ... +%d more", len(reqs)-10))) + } +} + +var scopeActionMap = map[string]string{ + "get": "getting", "list": "listing", "create": "creating", "update": "updating", "delete": "deleting", +} + +func renderScopeRules(args map[string]any, result any, status string) string { + var b strings.Builder + b.WriteString(Dim().Render(proxyIcon)) + action := StringValue(args["action"]) + actionText, ok := scopeActionMap[action] + if !ok { + if action != "" { + actionText = action + "ing" + } else { + actionText = "managing" + } + } + b.WriteString(Col(Cyan).Render(" " + actionText + " proxy scope")) + if sn := StringValue(args["scope_name"]); sn != "" { + b.WriteString(Dim().Italic(true).Render(" '" + ptrunc(sn, 50) + "'")) + } + if sid := StringValue(args["scope_id"]); sid != "" { + b.WriteString(Dim().Render(" #" + truncStr(sid, 8))) + } + writeList := func(key, label string) { + if items, ok := args[key].([]any); ok && len(items) > 0 { + shown := items + if len(shown) > 4 { + shown = shown[:4] + } + var parts []string + for _, it := range shown { + parts = append(parts, ptrunc(StringValue(it), 40)) + } + b.WriteString("\n " + Dim().Render(label+": "+strings.Join(parts, ", "))) + if len(items) > 4 { + b.WriteString(Dim().Italic(true).Render(fmt.Sprintf(" +%d", len(items)-4))) + } + } + } + writeList("allowlist", "allow") + writeList("denylist", "deny") + if status == "completed" { + if m, ok := resultMapOf(result); ok { + switch { + case m["error"] != nil: + b.WriteString(Col(Red).Render(" error: " + psanitize(StringValue(m["error"]), 150))) + case m["scopes"] != nil: + scopes, _ := m["scopes"].([]any) + b.WriteString(Dim().Render(fmt.Sprintf(" [%d scopes]", len(scopes)))) + renderScopeList(&b, scopes) + case m["scope"] != nil: + if scope, ok := m["scope"].(map[string]any); ok { + if allow, ok := scope["allowlist"].([]any); ok && len(allow) > 0 { + b.WriteString("\n " + Dim().Render("allow: "+joinTrunc(allow, 40, 5))) + } + if deny, ok := scope["denylist"].([]any); ok && len(deny) > 0 { + b.WriteString("\n " + Dim().Render("deny: "+joinTrunc(deny, 40, 5))) + } + } + case m["message"] != nil: + b.WriteString(Col(Green).Render(" " + StringValue(m["message"]))) + } + } + } + return b.String() +} + +func renderScopeList(b *strings.Builder, scopes []any) { + if len(scopes) == 0 { + return + } + b.WriteString("\n") + limit := len(scopes) + if limit > 5 { + limit = 5 + } + for i := 0; i < limit; i++ { + scope, ok := scopes[i].(map[string]any) + if !ok { + continue + } + name := StringValue(scope["name"]) + if name == "" { + name = "?" + } + b.WriteString(" " + Col(Green).Render(ptrunc(name, 40))) + if allow, ok := scope["allowlist"].([]any); ok && len(allow) > 0 { + b.WriteString(Dim().Render(" " + joinTrunc(allow, 30, 3))) + if len(allow) > 3 { + b.WriteString(Dim().Italic(true).Render(fmt.Sprintf(" +%d", len(allow)-3))) + } + } + if i < limit-1 { + b.WriteString("\n") + } + } +} diff --git a/strix/interface/tui/internal/render/registry.go b/strix/interface/tui/internal/render/registry.go new file mode 100644 index 00000000..3c235b0a --- /dev/null +++ b/strix/interface/tui/internal/render/registry.go @@ -0,0 +1,135 @@ +package render + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// statusIcon ports BaseToolRenderer.status_icon. +func statusIcon(status string) (string, lipgloss.Style) { + switch status { + case "running": + return "● In progress...", Col(AmberY) + case "completed": + return "βœ“ Done", Col(Green) + case "failed": + return "βœ— Failed", Col(SevCrit) + case "error": + return "βœ— Error", Col(SevCrit) + } + return "β—‹ Unknown", Dim() +} + +// renderGenericTool ports registry._render_default_tool_widget. +func renderGenericTool(name string, args map[string]any, result any, status string) string { + var b strings.Builder + b.WriteString(Dim().Render("β†’ Using tool ") + Bold(Blue).Render(name) + "\n") + for _, k := range SortedKeys(args) { + b.WriteString(" " + Dim().Render(k) + ": " + StringValue(args[k]) + "\n") + } + if (status == "completed" || status == "failed" || status == "error") && result != nil { + b.WriteString(lipgloss.NewStyle().Bold(true).Render("Result: ") + StringValue(result)) + } else { + icon, style := statusIcon(status) + b.WriteString(style.Render(icon)) + } + return b.String() +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +func Tool(data map[string]any) string { + name := StringValue(data["tool_name"]) + status := StringValue(data["status"]) + args, _ := data["args"].(map[string]any) + if args == nil { + args = map[string]any{} + } + result := data["result"] + + switch name { + case "exec_command": + return renderExecCommand(args, result, status) + case "write_stdin": + return renderWriteStdin(args, result, status) + case "apply_patch": + return renderApplyPatch(args, result, status) + case "view_image": + return renderViewImage(args, result) + case "create_vulnerability_report": + return renderVulnerabilityReport(args, result) + case "create_dependency_report": + return renderDependencyReport(args, result) + case "list_reports": + return renderListReports(result) + case "get_report": + return renderGetReport(result) + case "respond_to_user": + return renderRespondToUser(args) + case "finish_scan": + return renderFinishScan(args) + case "think": + return renderThink(args) + case "web_search": + return renderWebSearch(args) + case "load_skill": + return renderLoadSkill(args, result) + case "create_note", "delete_note", "update_note", "list_notes", "get_note": + return renderNote(name, args, result) + case "create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo": + return renderTodo(name, result) + case "view_agent_graph", "create_agent", "send_message_to_agent", "agent_finish", "wait_for_agents", "stop_agent": + return renderAgentGraphTool(name, args, result) + case "list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules": + return renderProxyTool(name, args, result, status) + } + return renderGenericTool(name, args, result, status) +} + +// --------------------------------------------------------------------------- +// Collapsing: output-heavy tools (terminal, proxy) render as short block +// previews; clicking a tool in the trace expands it to the full render. +// --------------------------------------------------------------------------- + +const outputPreviewLines = 10 + +// ToolPreviewLines returns how many lines of a tool's render are shown before +// it is collapsed; 0 means the tool is never collapsed. Only tools whose +// output can grow unbounded (terminal, patches, proxy) collapse. +func ToolPreviewLines(name string) int { + switch name { + case "exec_command", "write_stdin", "apply_patch", + "view_request", "repeat_request", "view_sitemap_entry": + return outputPreviewLines + } + return 0 +} + +// CollapseTool clips a full tool render to its preview size, appending a +// click-to-expand/collapse hint. It reports whether the tool has more content +// than the preview (i.e. whether it is expandable). +func CollapseTool(full, name string, expanded bool) (string, bool) { + maxLines := ToolPreviewLines(name) + if maxLines <= 0 { + return full, false + } + lines := strings.Split(full, "\n") + if len(lines) <= maxLines { + return full, false + } + if expanded { + return full + "\n" + Dim().Italic(true).Render(" β–² click to collapse"), true + } + preview := strings.Join(lines[:maxLines], "\n") + hidden := len(lines) - maxLines + plural := "s" + if hidden == 1 { + plural = "" + } + hint := Dim().Italic(true).Render(fmt.Sprintf(" … +%d line%s β€” click to expand", hidden, plural)) + return preview + "\n" + hint, true +} diff --git a/strix/interface/tui/internal/render/render_test.go b/strix/interface/tui/internal/render/render_test.go new file mode 100644 index 00000000..f9ed7f84 --- /dev/null +++ b/strix/interface/tui/internal/render/render_test.go @@ -0,0 +1,251 @@ +package render + +import ( + "fmt" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func tool(name string, args map[string]any, result any, status string) map[string]any { + data := map[string]any{"tool_name": name, "status": status} + if args != nil { + data["args"] = args + } + if result != nil { + data["result"] = result + } + return data +} + +func requireContains(t *testing.T, output string, wants ...string) { + t.Helper() + for _, want := range wants { + if !strings.Contains(output, want) { + t.Fatalf("output missing %q:\n%s", want, output) + } + } +} + +func TestChatUserMessage(t *testing.T) { + out := Chat(map[string]any{"role": "user", "content": "hello\nworld"}) + requireContains(t, out, "You:", "hello", "world") +} + +func TestChatAssistantMarkdown(t *testing.T) { + out := Chat(map[string]any{"role": "assistant", "content": "# Heading\n\nSome **bold** text"}) + requireContains(t, out, "Heading", "bold") +} + +func TestExecCommandHighlightsCommand(t *testing.T) { + out := Tool(tool("exec_command", map[string]any{"cmd": "for f in *.py; do echo \"$f\"; done"}, nil, "running")) + if !strings.Contains(out, "\x1b[38;5;") { + t.Fatalf("expected syntax-highlighted command:\n%q", out) + } +} + +func TestApplyPatchHighlightsCode(t *testing.T) { + out := Tool(tool("apply_patch", map[string]any{ + "patch": "*** Update File: src/app.py\n-import os\n+import sys\n+def main():\n+ return sys.argv", + }, nil, "completed")) + if !strings.Contains(out, "\x1b[38;5;") { + t.Fatalf("expected syntax-highlighted patch lines:\n%q", out) + } + lines := strings.Split(out, "\n") + if len(lines) != 5 { + t.Fatalf("diff line structure must survive highlighting, got %d lines:\n%q", len(lines), out) + } +} + +func TestToolDispatchCoversKnownTools(t *testing.T) { + cases := []struct { + name string + data map[string]any + wants []string + }{ + { + "exec_command", + tool("exec_command", map[string]any{"cmd": "ls -la"}, nil, "running"), + []string{"ls -la"}, + }, + { + "write_stdin", + tool("write_stdin", map[string]any{"chars": "y", "session_id": 3}, nil, "completed"), + []string{"y", "session #3"}, + }, + { + "apply_patch", + tool("apply_patch", map[string]any{ + "file_path": "src/app.py", + "patch": "*** Update File: src/app.py\n+new line", + }, nil, "completed"), + []string{"src/app.py"}, + }, + { + "view_image", + tool("view_image", map[string]any{"path": "shot.png"}, nil, "completed"), + []string{"shot.png"}, + }, + { + "create_vulnerability_report", + tool("create_vulnerability_report", + map[string]any{"title": "SQL injection in login", "target": "https://x.test"}, + map[string]any{"severity": "critical", "cvss_score": 9.8}, + "completed"), + []string{"Vulnerability Report", "SQL injection in login", "CRITICAL", "9.8"}, + }, + { + "create_dependency_report", + tool("create_dependency_report", + map[string]any{"package_name": "requests", "installed_version": "2.0.0"}, + nil, "completed"), + []string{"requests"}, + }, + { + "list_reports", + tool("list_reports", nil, map[string]any{ + "success": true, + "total_count": 2, + "severity_counts": map[string]any{"critical": 1, "low": 1}, + "reports": []any{ + map[string]any{"id": "VULN-1", "title": "SQLi", "severity": "critical", "by_you": true}, + map[string]any{"id": "VULN-2", "title": "Weak header", "severity": "low", "agent_name": "recon"}, + }, + }, "completed"), + []string{"reports", "(2)", "CRITICAL", "VULN-1", "SQLi", "(you)", "LOW", "VULN-2", "(recon)"}, + }, + { + "list_reports empty", + tool("list_reports", nil, map[string]any{"success": true, "total_count": 0}, "completed"), + []string{"reports", "(0)", "No reports filed yet"}, + }, + { + "get_report", + tool("get_report", nil, map[string]any{ + "success": true, + "report": map[string]any{ + "id": "VULN-1", "title": "SQLi", "severity": "high", "target": "https://x.test", + }, + }, "completed"), + []string{"report read", "HIGH", "VULN-1", "SQLi", "https://x.test"}, + }, + { + "get_report error", + tool("get_report", nil, map[string]any{"success": false, "error": "not found"}, "failed"), + []string{"report read", "not found"}, + }, + { + "respond_to_user", + tool("respond_to_user", map[string]any{"message": "Here is the answer"}, nil, "completed"), + []string{"Here is the answer", "waiting for your reply"}, + }, + { + "finish_scan", + tool("finish_scan", map[string]any{"executive_summary": "All done"}, nil, "completed"), + []string{"Penetration test completed", "All done"}, + }, + { + "think", + tool("think", map[string]any{"thought": "checking auth flow"}, nil, "running"), + []string{"Thinking", "checking auth flow"}, + }, + { + "web_search", + tool("web_search", map[string]any{"query": "CVE-2024-1234"}, nil, "running"), + []string{"Searching the web", "CVE-2024-1234"}, + }, + { + "load_skill", + tool("load_skill", map[string]any{"skills": []any{"sqli"}}, nil, "completed"), + []string{"sqli"}, + }, + { + "create_note", + tool("create_note", map[string]any{"title": "Recon findings"}, nil, "completed"), + []string{"Recon findings"}, + }, + { + "create_todo", + tool("create_todo", nil, map[string]any{ + "success": true, + "todos": []any{ + map[string]any{"id": 1, "title": "Check login", "status": "pending"}, + }, + }, "completed"), + []string{"Check login"}, + }, + { + "create_agent", + tool("create_agent", map[string]any{"name": "ReconAgent", "task": "map the site"}, nil, "running"), + []string{"spawning", "ReconAgent", "map the site"}, + }, + { + "wait_for_agents", + tool("wait_for_agents", map[string]any{"reason": "results needed"}, nil, "running"), + []string{"waiting", "results needed"}, + }, + { + "stop_agent", + tool("stop_agent", map[string]any{"target_agent_id": "agent-2"}, nil, "completed"), + []string{"stopping", "agent-2"}, + }, + { + "view_agent_graph", + tool("view_agent_graph", nil, nil, "completed"), + []string{"viewing agents graph"}, + }, + { + "list_requests", + tool("list_requests", map[string]any{"httpql_filter": "host:example.com"}, nil, "completed"), + []string{"host:example.com"}, + }, + { + "unknown tool falls back to generic", + tool("brand_new_tool", map[string]any{"alpha": "1"}, "done", "completed"), + []string{"brand_new_tool", "alpha", "Result:", "done"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + requireContains(t, Tool(tc.data), tc.wants...) + }) + } +} + +func TestCollapseToolShellPreviewAndExpand(t *testing.T) { + lines := make([]string, 16) + for i := range lines { + lines[i] = fmt.Sprintf("line %d", i) + } + full := strings.Join(lines, "\n") + + collapsed, expandable := CollapseTool(full, "exec_command", false) + if !expandable { + t.Fatal("long shell output should be expandable") + } + got := strings.Split(ansi.Strip(collapsed), "\n") + if len(got) != 11 || !strings.Contains(got[10], "+6 lines β€” click to expand") { + t.Fatalf("collapsed shell preview wrong: %q", got) + } + + expanded, expandable := CollapseTool(full, "exec_command", true) + if !expandable || !strings.Contains(ansi.Strip(expanded), full) || + !strings.Contains(ansi.Strip(expanded), "click to collapse") { + t.Fatalf("expanded render wrong: %q", expanded) + } +} + +func TestCollapseToolOnlyOutputHeavyTools(t *testing.T) { + full := "🧠 Thinking\n a long private thought\n spanning lines" + if out, expandable := CollapseTool(full, "think", false); expandable || out != full { + t.Fatal("think must never collapse") + } + if _, expandable := CollapseTool("short", "exec_command", false); expandable { + t.Fatal("short output must not be expandable") + } + if out, expandable := CollapseTool(full, "respond_to_user", false); expandable || out != full { + t.Fatal("respond_to_user must never collapse") + } +} diff --git a/strix/interface/tui/internal/render/report.go b/strix/interface/tui/internal/render/report.go new file mode 100644 index 00000000..0002fdb2 --- /dev/null +++ b/strix/interface/tui/internal/render/report.go @@ -0,0 +1,126 @@ +package render + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// Reporting (reporting_renderer.py) +// --------------------------------------------------------------------------- + +func renderVulnerabilityReport(args map[string]any, result any) string { + resultMap, _ := result.(map[string]any) + var b strings.Builder + b.WriteString("🐞 " + Bold(ReportHdr).Render("Vulnerability Report")) + + field := func(label, value string) { + if value != "" { + b.WriteString("\n\n" + Bold(Field).Render(label+": ") + value) + } + } + title := StringValue(args["title"]) + field("Title", title) + + if sev := StringValue(resultMap["severity"]); sev != "" { + b.WriteString("\n\n" + Bold(Field).Render("Severity: ") + + lipgloss.NewStyle().Bold(true).Foreground(SeverityColor(sev)).Render(strings.ToUpper(sev))) + } + if score, ok := NumericValue(resultMap["cvss_score"]); ok { + b.WriteString("\n\n" + Bold(Field).Render("CVSS Score: ") + + lipgloss.NewStyle().Bold(true).Foreground(CVSSColor(score)).Render(StringValue(resultMap["cvss_score"]))) + } + field("Target", StringValue(args["target"])) + field("Endpoint", StringValue(args["endpoint"])) + field("Method", StringValue(args["method"])) + field("CVE", StringValue(args["cve"])) + field("CWE", StringValue(args["cwe"])) + + if bd, ok := args["cvss_breakdown"].(map[string]any); ok && len(bd) > 0 { + parts := CVSSVectorParts(bd) + if len(parts) > 0 { + b.WriteString("\n\n" + Bold(Field).Render("CVSS Vector: ") + Dim().Render(strings.Join(parts, "/"))) + } + } + + section := func(label, value string) { + if value != "" { + b.WriteString("\n\n" + Bold(Field).Render(label) + "\n" + value) + } + } + section("Description", StringValue(args["description"])) + section("Impact", StringValue(args["impact"])) + section("Technical Analysis", StringValue(args["technical_analysis"])) + renderCodeLocations(&b, args["code_locations"]) + section("PoC Description", StringValue(args["poc_description"])) + if poc := StringValue(args["poc_script_code"]); poc != "" { + b.WriteString("\n\n" + Bold(Field).Render("PoC Code") + "\n" + Col(Text).Render(poc)) + } + section("Remediation", StringValue(args["remediation_steps"])) + + if title == "" { + b.WriteString("\n " + Dim().Render("Creating report...")) + } + return "\n\n" + b.String() + "\n\n" +} + +var cvssKeys = [][2]string{ + {"attack_vector", "AV"}, {"attack_complexity", "AC"}, {"privileges_required", "PR"}, + {"user_interaction", "UI"}, {"scope", "S"}, {"confidentiality", "C"}, + {"integrity", "I"}, {"availability", "A"}, +} + +func CVSSVectorParts(bd map[string]any) []string { + var parts []string + for _, kp := range cvssKeys { + if v := StringValue(bd[kp[0]]); v != "" { + parts = append(parts, kp[1]+":"+v) + } + } + return parts +} + +func renderCodeLocations(b *strings.Builder, raw any) { + locs, ok := raw.([]any) + if !ok || len(locs) == 0 { + return + } + b.WriteString("\n\n" + Bold(Field).Render("Code Locations")) + for i, l := range locs { + loc, ok := l.(map[string]any) + if !ok { + continue + } + b.WriteString("\n\n" + Dim().Render(fmt.Sprintf(" Location %d: ", i+1))) + file := StringValue(loc["file"]) + if file == "" { + file = "unknown" + } + b.WriteString(Bold(InfoBlue).Render(file)) + if start, ok := NumericValue(loc["start_line"]); ok { + if end, ok := NumericValue(loc["end_line"]); ok && end != start { + b.WriteString(Col(LineNum).Render(fmt.Sprintf(":%d-%d", int(start), int(end)))) + } else { + b.WriteString(Col(LineNum).Render(fmt.Sprintf(":%d", int(start)))) + } + } + if label := StringValue(loc["label"]); label != "" { + b.WriteString(lipgloss.NewStyle().Italic(true).Foreground(Label).Render("\n " + label)) + } + if snip := StringValue(loc["snippet"]); snip != "" { + b.WriteString("\n " + Col(Snippet).Render(snip)) + } + before, after := StringValue(loc["fix_before"]), StringValue(loc["fix_after"]) + if before != "" || after != "" { + b.WriteString("\n " + Dim().Render("Fix:")) + if before != "" { + b.WriteString("\n " + Col(Red).Render("- ") + Col(Red).Render(before)) + } + if after != "" { + b.WriteString("\n " + Col(Green).Render("+ ") + Col(Green).Render(after)) + } + } + } +} diff --git a/strix/interface/tui/internal/render/report_list.go b/strix/interface/tui/internal/render/report_list.go new file mode 100644 index 00000000..aa19293d --- /dev/null +++ b/strix/interface/tui/internal/render/report_list.go @@ -0,0 +1,129 @@ +package render + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// Report browsing (reporting_renderer.py: ListReportsRenderer, GetReportRenderer) +// --------------------------------------------------------------------------- + +// listSeverityColor mirrors reporting_renderer._severity_style, whose fallback +// is medium rather than the vulnerability report's neutral gray. +func listSeverityColor(severity string) lipgloss.Color { + switch strings.ToLower(severity) { + case "critical": + return SevCrit + case "high": + return SevHigh + case "medium": + return SevMed + case "low": + return SevLow + case "info": + return SevInfo + case "none": + return Gray + } + return SevMed +} + +// authorLabel ports reporting_renderer._author_label. +func authorLabel(report map[string]any) string { + if by, ok := report["by_you"].(bool); ok && by { + return "you" + } + return strings.TrimSpace(StringValue(report["agent_name"])) +} + +func reportSummaryLine(b *strings.Builder, report map[string]any, prefix string) { + id := strings.TrimSpace(StringValue(report["id"])) + title := strings.TrimSpace(StringValue(report["title"])) + if title == "" { + title = "(untitled)" + } + severity := strings.TrimSpace(StringValue(report["severity"])) + b.WriteString(prefix) + if severity != "" { + b.WriteString(Bold(listSeverityColor(severity)).Render(strings.ToUpper(severity)) + " ") + } + if id != "" { + b.WriteString(Dim().Render(id + " ")) + } + b.WriteString(title) + if author := authorLabel(report); author != "" { + b.WriteString(Dim().Render(" (" + author + ")")) + } +} + +func renderListReports(result any) string { + var b strings.Builder + b.WriteString(Col(Red).Render("β—† ") + Dim().Render("reports")) + + if text, ok := result.(string); ok && strings.TrimSpace(text) != "" { + b.WriteString("\n " + Dim().Render(strings.TrimSpace(text))) + return b.String() + } + + resultMap, _ := result.(map[string]any) + success, _ := resultMap["success"].(bool) + if !success { + b.WriteString("\n " + Dim().Render("Loading...")) + return b.String() + } + + if total, ok := NumericValue(resultMap["total_count"]); ok { + b.WriteString(Dim().Render(fmt.Sprintf(" (%d)", int(total)))) + } else { + b.WriteString(Dim().Render(" (0)")) + } + if counts, ok := resultMap["severity_counts"].(map[string]any); ok { + for _, severity := range SortedKeys(counts) { + b.WriteString(" " + Col(listSeverityColor(severity)).Render( + severity+" "+StringValue(counts[severity]))) + } + } + + reports, _ := resultMap["reports"].([]any) + if len(reports) == 0 { + b.WriteString("\n " + Dim().Render("No reports filed yet")) + return b.String() + } + for _, raw := range reports { + report, ok := raw.(map[string]any) + if !ok { + continue + } + reportSummaryLine(&b, report, "\n - ") + } + return b.String() +} + +func renderGetReport(result any) string { + var b strings.Builder + b.WriteString(Col(Red).Render("β—† ") + Dim().Render("report read")) + + resultMap, _ := result.(map[string]any) + success, _ := resultMap["success"].(bool) + report, _ := resultMap["report"].(map[string]any) + if !success || len(report) == 0 { + detail := "" + if hasSuccess, ok := resultMap["success"].(bool); ok && !hasSuccess { + detail = StringValue(resultMap["error"]) + } + if detail == "" { + detail = "Loading..." + } + b.WriteString("\n " + Dim().Render(detail)) + return b.String() + } + + reportSummaryLine(&b, report, "\n ") + if target := strings.TrimSpace(StringValue(report["target"])); target != "" { + b.WriteString("\n " + Dim().Render(target)) + } + return b.String() +} diff --git a/strix/interface/tui/internal/render/respond.go b/strix/interface/tui/internal/render/respond.go new file mode 100644 index 00000000..7b8452a3 --- /dev/null +++ b/strix/interface/tui/internal/render/respond.go @@ -0,0 +1,18 @@ +package render + +import "strings" + +// --------------------------------------------------------------------------- +// Direct replies (respond_renderer.py) +// --------------------------------------------------------------------------- + +// renderRespondToUser shows the reply as the agent's own prose, since +// respond_to_user carries the message the user is meant to read. +func renderRespondToUser(args map[string]any) string { + var b strings.Builder + if message := StringValue(args["message"]); message != "" { + b.WriteString(renderAssistantMarkdown(message) + "\n\n") + } + b.WriteString(Col(Gray).Render("β—‹ ") + Dim().Render("waiting for your reply")) + return b.String() +} diff --git a/strix/interface/tui/internal/render/scan.go b/strix/interface/tui/internal/render/scan.go new file mode 100644 index 00000000..d1fc75c5 --- /dev/null +++ b/strix/interface/tui/internal/render/scan.go @@ -0,0 +1,31 @@ +package render + +import ( + "strings" +) + +// --------------------------------------------------------------------------- +// Finish scan (finish_renderer.py) +// --------------------------------------------------------------------------- + +func renderFinishScan(args map[string]any) string { + var b strings.Builder + b.WriteString(Col(Green).Render("β—† ") + Bold(Green).Render("Penetration test completed")) + section := func(label, value string) { + if value != "" { + b.WriteString("\n\n" + Bold(Field).Render(label) + "\n" + value) + } + } + es := StringValue(args["executive_summary"]) + me := StringValue(args["methodology"]) + ta := StringValue(args["technical_analysis"]) + re := StringValue(args["recommendations"]) + section("Executive Summary", es) + section("Methodology", me) + section("Technical Analysis", ta) + section("Recommendations", re) + if es == "" && me == "" && ta == "" && re == "" { + b.WriteString("\n " + Dim().Render("Generating final report...")) + } + return "\n\n" + b.String() + "\n\n" +} diff --git a/strix/interface/tui/internal/render/simple.go b/strix/interface/tui/internal/render/simple.go new file mode 100644 index 00000000..2dfa5641 --- /dev/null +++ b/strix/interface/tui/internal/render/simple.go @@ -0,0 +1,52 @@ +package render + +import ( + "strings" +) + +// --------------------------------------------------------------------------- +// Simple tools (think, web_search, load_skill) + generic fallback +// --------------------------------------------------------------------------- + +func renderThink(args map[string]any) string { + thought := StringValue(args["thought"]) + var b strings.Builder + b.WriteString("🧠 " + Bold(Purple).Render("Thinking") + "\n ") + if thought != "" { + b.WriteString(Dim().Italic(true).Render(thought)) + } else { + b.WriteString(Dim().Italic(true).Render("Thinking...")) + } + return b.String() +} + +func renderWebSearch(args map[string]any) string { + query := StringValue(args["query"]) + var b strings.Builder + b.WriteString("🌐 " + Bold(InfoBlue).Render("Searching the web...")) + if query != "" { + b.WriteString("\n " + Dim().Render(query)) + } + return b.String() +} + +func renderLoadSkill(args map[string]any, result any) string { + var requested string + if list, ok := args["skills"].([]any); ok { + var parts []string + for _, s := range list { + parts = append(parts, StringValue(s)) + } + requested = strings.Join(parts, ", ") + } else { + requested = StringValue(args["skills"]) + } + var b strings.Builder + b.WriteString(Col(Emerald).Render("β—‡ ") + Dim().Render("loading skill")) + if requested != "" { + b.WriteString(" " + Col(Emerald).Render(requested)) + } else if result == nil { + b.WriteString("\n " + Dim().Render("Loading...")) + } + return b.String() +} diff --git a/strix/interface/tui/internal/render/styles.go b/strix/interface/tui/internal/render/styles.go new file mode 100644 index 00000000..9fc99043 --- /dev/null +++ b/strix/interface/tui/internal/render/styles.go @@ -0,0 +1,82 @@ +// Package render turns chat and tool events into styled terminal output, +// with one file per tool renderer. +package render + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Colors and shared lipgloss style helpers used across the renderers. +// Rich's "dim" attribute maps to lipgloss Faint. +var ( + Green = lipgloss.Color("#22c55e") + Blue = lipgloss.Color("#3b82f6") + Red = lipgloss.Color("#ef4444") + Text = lipgloss.Color("#d4d4d4") + Field = lipgloss.Color("#4ade80") // FIELD_STYLE base (bold) + ReportHdr = lipgloss.Color("#ea580c") // report title / orange + SevCrit = lipgloss.Color("#dc2626") + SevHigh = lipgloss.Color("#ea580c") + SevMed = lipgloss.Color("#d97706") + SevLow = lipgloss.Color("#65a30d") + SevInfo = lipgloss.Color("#0284c7") + Gray = lipgloss.Color("#6b7280") + Purple = lipgloss.Color("#a855f7") // thinking + Lavender = lipgloss.Color("#a78bfa") // todos / agent graph + Emerald = lipgloss.Color("#10b981") // skills / patch ops + Gold = lipgloss.Color("#fbbf24") // notes + AmberY = lipgloss.Color("#f59e0b") // running icon / reopened + LineNum = lipgloss.Color("#facc15") + Label = lipgloss.Color("#a1a1aa") + Snippet = lipgloss.Color("#e2e8f0") + Slate = lipgloss.Color("#94a3b8") + Cyan = lipgloss.Color("#06b6d4") // proxy + Status3xx = lipgloss.Color("#eab308") + Status4xx = lipgloss.Color("#f97316") + Hdr16a = lipgloss.Color("#16a34a") + Hdr158 = lipgloss.Color("#15803d") + Mint = lipgloss.Color("#86efac") + Strike = lipgloss.Color("#525252") + CodeBg = lipgloss.Color("#0a0a0a") + InfoBlue = lipgloss.Color("#60a5fa") +) + +// Style helpers. Col() foreground; Dim() Rich "dim" (faint attribute). +func Col(c lipgloss.Color) lipgloss.Style { return lipgloss.NewStyle().Foreground(c) } +func Dim() lipgloss.Style { return lipgloss.NewStyle().Faint(true) } +func Bold(c lipgloss.Color) lipgloss.Style { + return lipgloss.NewStyle().Bold(true).Foreground(c) +} + +// severityColor maps a severity string to the report renderer's color. +func SeverityColor(sev string) lipgloss.Color { + switch strings.ToLower(sev) { + case "critical": + return SevCrit + case "high": + return SevHigh + case "medium": + return SevMed + case "low": + return SevLow + case "info": + return SevInfo + } + return Gray +} + +func CVSSColor(score float64) lipgloss.Color { + switch { + case score >= 9.0: + return SevCrit + case score >= 7.0: + return SevHigh + case score >= 4.0: + return SevMed + case score >= 0.1: + return SevLow + } + return Gray +} diff --git a/strix/interface/tui/internal/render/terminal.go b/strix/interface/tui/internal/render/terminal.go new file mode 100644 index 00000000..059e1936 --- /dev/null +++ b/strix/interface/tui/internal/render/terminal.go @@ -0,0 +1,183 @@ +package render + +import ( + "fmt" + "regexp" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// Shell renderer (shell_renderer.py) +// --------------------------------------------------------------------------- + +const ( + maxOutputLines = 50 + maxLineLength = 200 +) + +var ( + exitRE = regexp.MustCompile(`Process exited with code (-?\d+)`) + sessionRE = regexp.MustCompile(`Process running with session ID (\d+)`) + stripRE = regexp.MustCompile(`(?m)^(Chunk ID: [0-9a-f]+|Wall time: [\d.]+ seconds|Process exited with code -?\d+|Process running with session ID \d+|Original token count: \d+)\s*$`) +) + +const outputHeader = "\nOutput:\n" + +type shellParsed struct { + content string + exitCode int + hasExitCode bool +} + +func parseShellResult(result any) shellParsed { + if m, ok := result.(map[string]any); ok { + p := shellParsed{content: StringValue(m["content"])} + if code, ok := NumericValue(m["exit_code"]); ok { + p.exitCode, p.hasExitCode = int(code), true + } + return p + } + s, ok := result.(string) + if !ok { + if result == nil { + return shellParsed{} + } + return shellParsed{content: StringValue(result)} + } + p := shellParsed{} + if m := exitRE.FindStringSubmatch(s); m != nil { + fmt.Sscanf(m[1], "%d", &p.exitCode) + p.hasExitCode = true + } + if idx := strings.Index(s, outputHeader); idx >= 0 { + p.content = s[idx+len(outputHeader):] + } else { + p.content = s + } + return p +} + +func cleanShellOutput(output string) string { + cleaned := stripControlsKeepTabs(output) + cleaned = stripRE.ReplaceAllString(cleaned, "") + if strings.TrimSpace(cleaned) == "" { + return "" + } + lines := strings.Split(cleaned, "\n") + var filtered []string + for _, line := range lines { + if len(filtered) == 0 && strings.TrimSpace(line) == "" { + continue + } + if strings.TrimSpace(line) == "Output:" { + continue + } + filtered = append(filtered, line) + } + for len(filtered) > 0 && strings.TrimSpace(filtered[len(filtered)-1]) == "" { + filtered = filtered[:len(filtered)-1] + } + return strings.TrimSpace(strings.Join(filtered, "\n")) +} + +func truncateShellLine(line string) string { + if len(line) > maxLineLength { + return line[:maxLineLength-3] + "..." + } + return line +} + +// formatShellOutput ports _format_output (head/tail truncation with a middle marker). +func formatShellOutput(output string) string { + lines := strings.Split(output, "\n") + total := len(lines) + head := maxOutputLines / 2 + tail := maxOutputLines - head - 1 + + var b strings.Builder + if total <= maxOutputLines { + for i, line := range lines { + b.WriteString(" " + Dim().Render(truncateShellLine(line))) + if i < len(lines)-1 { + b.WriteString("\n") + } + } + return b.String() + } + + display := lines[:head] + hidden := total - head - tail + for _, line := range display { + b.WriteString(" " + Dim().Render(truncateShellLine(line)) + "\n") + } + b.WriteString(Dim().Italic(true).Render(fmt.Sprintf(" ... %d lines truncated ...", hidden)) + "\n") + tailLines := lines[total-tail:] + for i, line := range tailLines { + b.WriteString(" " + Dim().Render(truncateShellLine(line))) + if i < len(tailLines)-1 { + b.WriteString("\n") + } + } + return b.String() +} + +func appendShellOutput(b *strings.Builder, p shellParsed, status string) { + output := cleanShellOutput(p.content) + if status == "running" { + if output != "" { + b.WriteString("\n" + formatShellOutput(output)) + } + return + } + if output == "" { + if p.hasExitCode && p.exitCode != 0 { + b.WriteString("\n" + Col(Red).Faint(true).Render(fmt.Sprintf(" exit %d", p.exitCode))) + } + return + } + b.WriteString("\n" + formatShellOutput(output)) + if p.hasExitCode && p.exitCode != 0 { + b.WriteString("\n" + Col(Red).Faint(true).Render(fmt.Sprintf(" exit %d", p.exitCode))) + } +} + +func renderTerminal(prompt string, promptColor lipgloss.Color, command string, result any, status, meta string) string { + var b strings.Builder + b.WriteString(Dim().Render(">_") + " ") + if strings.TrimSpace(command) == "" { + b.WriteString(Dim().Render("getting logs...")) + } else { + b.WriteString(Col(promptColor).Render(prompt) + " " + command) + } + if meta != "" { + b.WriteString(Dim().Render(" " + meta)) + } + if result != nil { + appendShellOutput(&b, parseShellResult(result), status) + } + return b.String() +} + +func renderExecCommand(args map[string]any, result any, status string) string { + cmd := StringValue(args["cmd"]) + var metaParts []string + if wd := StringValue(args["workdir"]); wd != "" { + metaParts = append(metaParts, "cwd:"+wd) + } + if b, ok := args["tty"].(bool); ok && b { + metaParts = append(metaParts, "tty") + } + meta := strings.Join(metaParts, ", ") + return renderTerminal("$", Green, HighlightCode(cmd, "bash"), result, status, meta) +} + +func renderWriteStdin(args map[string]any, result any, status string) string { + chars := StringValue(args["chars"]) + meta := "" + if sid, ok := args["session_id"]; ok && sid != nil { + meta = "session #" + StringValue(sid) + } + return renderTerminal(">>>", Blue, chars, result, status, meta) +} diff --git a/strix/interface/tui/internal/render/todo.go b/strix/interface/tui/internal/render/todo.go new file mode 100644 index 00000000..8ec09b2f --- /dev/null +++ b/strix/interface/tui/internal/render/todo.go @@ -0,0 +1,80 @@ +package render + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// Todos (todo_renderer.py) +// --------------------------------------------------------------------------- + +var todoMarkers = map[string]string{"pending": "[ ]", "in_progress": "[~]", "done": "[β€’]"} + +var todoTitles = map[string]struct { + title string + color lipgloss.Color + loading string + errMsg string +}{ + "create_todo": {"Todo", Lavender, "Creating...", "Failed to create todo"}, + "list_todos": {"Todos", Lavender, "Loading...", "Unable to list todos"}, + "update_todo": {"Todo Updated", Lavender, "Updating...", "Failed to update todo"}, + "mark_todo_done": {"Todo Completed", Lavender, "Marking done...", "Failed to mark todo done"}, + "mark_todo_pending": {"Todo Reopened", AmberY, "Reopening...", "Failed to reopen todo"}, + "delete_todo": {"Todo Removed", Slate, "Removing...", "Failed to remove todo"}, +} + +func renderTodo(name string, result any) string { + meta := todoTitles[name] + var b strings.Builder + b.WriteString("πŸ“‹ " + Bold(meta.color).Render(meta.title)) + if s, ok := result.(string); ok && strings.TrimSpace(s) != "" { + b.WriteString("\n " + Dim().Render(strings.TrimSpace(s))) + return b.String() + } + if m, ok := result.(map[string]any); ok { + if truthy(m["success"]) { + formatTodoLines(&b, m) + } else { + errMsg := StringValue(m["error"]) + if errMsg == "" { + errMsg = meta.errMsg + } + b.WriteString("\n " + Col(Red).Render(errMsg)) + } + } else { + b.WriteString("\n " + Dim().Render(meta.loading)) + } + return b.String() +} + +func formatTodoLines(b *strings.Builder, result map[string]any) { + todos, ok := result["todos"].([]any) + if !ok || len(todos) == 0 { + b.WriteString("\n " + Dim().Render("No todos")) + return + } + for _, t := range todos { + todo, _ := t.(map[string]any) + status := StringValue(todo["status"]) + marker := todoMarkers[status] + if marker == "" { + marker = todoMarkers["pending"] + } + title := strings.TrimSpace(StringValue(todo["title"])) + if title == "" { + title = "(untitled)" + } + b.WriteString("\n " + marker + " ") + switch status { + case "done": + b.WriteString(Dim().Strikethrough(true).Render(title)) + case "in_progress": + b.WriteString(lipgloss.NewStyle().Italic(true).Render(title)) + default: + b.WriteString(title) + } + } +} diff --git a/strix/interface/tui/live_view.py b/strix/interface/tui/live_view.py index 11a4b033..aa07dd36 100644 --- a/strix/interface/tui/live_view.py +++ b/strix/interface/tui/live_view.py @@ -10,6 +10,8 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from pathlib import Path +from agents.tool import ToolOutputImage + from strix.core.paths import runtime_state_dir from strix.interface.tui.history import load_session_history @@ -21,8 +23,57 @@ class TuiLiveView: self._next_event_id = 1 self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {} self._tool_event_by_agent_and_call_id: dict[tuple[str, str], dict[str, Any]] = {} + self._user_instruction: str | None = None + self._user_instruction_at: str | None = None + self._user_instruction_shown = False + + def set_user_instruction(self, text: str | None, *, timestamp: str | None = None) -> None: + """Open the transcript with what the user asked for. + + The prompt from the start screen, ``--instruction`` and + ``--instruction-file`` all reach the agent folded into its task, which the + transcript does not show. This replays it as their first message instead, + once, against the root agent - which may not exist yet, so it is held + until that agent appears. + """ + if self._user_instruction_shown or not (text or "").strip(): + return + self._user_instruction = str(text).strip() + self._user_instruction_at = timestamp + self.flush_user_instruction() + + def flush_user_instruction(self) -> bool: + """Post the held opening message once a root agent exists, once. + + Driven from wherever the agent graph is refreshed rather than from + ``upsert_agent``, which subclasses override without calling back here. + Returns whether it posted, so callers can report the change. + """ + if self._user_instruction_shown or not self._user_instruction: + return False + root_id = next( + (agent_id for agent_id, agent in self.agents.items() if agent.get("parent_id") is None), + None, + ) + if root_id is None: + return False + self._user_instruction_shown = True + self._append_event( + root_id, + "chat", + { + "role": "user", + "content": self._user_instruction, + "metadata": {"source": "user_instruction"}, + }, + timestamp=self._user_instruction_at, + ) + return True def hydrate_from_run_dir(self, run_dir: Path) -> None: + # Armed before the agents are added so the root agent's arrival puts the + # user's opening message ahead of the replayed history. + self._load_user_instruction(run_dir) state_dir = runtime_state_dir(run_dir) agents_path = state_dir / "agents.json" if not agents_path.exists(): @@ -45,14 +96,42 @@ class TuiLiveView: parent_id=parent_of.get(agent_id) if isinstance(parent_of, dict) else None, status=str(status), ) + # Ahead of the replayed history, so it opens the transcript. + self.flush_user_instruction() self._hydrate_sdk_session_history(run_dir, statuses.keys()) + def _load_user_instruction(self, run_dir: Path) -> None: + """Take the user's opening message from the run record, if it has one.""" + try: + record = json.loads((run_dir / "run.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + if not isinstance(record, dict): + return + instruction = record.get("user_instruction") + if not isinstance(instruction, str): + return + start_time = record.get("start_time") + # Stamped with the run's start so it sorts ahead of replayed history. + self.set_user_instruction( + instruction, + timestamp=start_time if isinstance(start_time, str) else None, + ) + def _hydrate_sdk_session_history(self, run_dir: Path, agent_ids: Any) -> None: + # An agent's first user turn is the task it was launched with, not + # something the user typed at it, so it is replayed as context rather + # than as a message. + tasked: set[str] = set() for agent_id, item, timestamp in load_session_history(run_dir, agent_ids): + first_user_turn = agent_id not in tasked + if item.get("role") == "user" and item.get("type") in {None, "message"}: + tasked.add(agent_id) self._ingest_session_history_item( agent_id, item, timestamp=timestamp, + first_user_turn=first_user_turn, ) def upsert_agent( @@ -144,22 +223,30 @@ class TuiLiveView: item: dict[str, Any], *, timestamp: str, + first_user_turn: bool = False, ) -> None: item_type = item.get("type") role = item.get("role") if role in {"user", "assistant"} and (item_type in {None, "message"}): content = _session_message_text(item) - if content: - self._append_event( - agent_id, - "chat", - { - "role": role, - "content": content, - "metadata": {"source": "sdk_session"}, - }, - timestamp=timestamp, - ) + if not content: + return + # A live run only shows what the user actually typed; the agent's + # task and the guidance the system feeds it stay out of the + # transcript. Replayed history has to make the same distinction, or + # resuming attributes all of it to the user. + if role == "user" and (first_user_turn or _is_internal_agent_turn(content)): + return + self._append_event( + agent_id, + "chat", + { + "role": role, + "content": content, + "metadata": {"source": "sdk_session"}, + }, + timestamp=timestamp, + ) return if item_type == "function_call": @@ -267,7 +354,7 @@ class TuiLiveView: ) self._tool_event_by_agent_and_call_id[event_key] = event - result = _parse_json_value(output["output"]) + result = _normalize_image_result(_parse_json_value(output["output"])) event["data"]["result"] = result event["data"]["status"] = _tool_status_from_result(result) self._bump_event(event, timestamp=timestamp) @@ -330,6 +417,35 @@ def _session_message_text(item: dict[str, Any]) -> str: return _message_content_text(item.get("content", "")) +# Guidance the system feeds an agent is injected as a user turn, which is the +# same shape a typed message takes, so replayed history cannot tell them apart by +# role alone. These are the exact openings it arrives with. Matching the full +# opening rather than just a leading bracket keeps pasted JSON, markdown links and +# a typed "[URGENT] stop" out of it. +_INTERNAL_TURN_PREFIXES = ( + # strix.core.agents._message_to_session_item: everything the coordinator + # delivers from another agent or from the system, which wraps the stall, + # terminal and budget-extension notices in strix.core.execution too. + "[Message from ", + # strix.core.inputs.child_initial_input: a subagent's parent context. + "== Inherited context from parent", + # strix.core.execution: the no-tool-call recovery nudge, both modes. + "Your previous message ended a turn without a tool call.", + "Your previous response ended the autonomous Strix run without a lifecycle tool call.", + # strix.core.hooks: budget warnings, the only notices injected unwrapped. + *( + f"[{label}] {subject}" + for label in ("NOTICE", "URGENT", "CRITICAL") + for subject in ("Turn budget:", "Scan cost budget:") + ), +) + + +def _is_internal_agent_turn(content: str) -> bool: + """Report whether a replayed user turn is system guidance, not a typed message.""" + return content.lstrip().startswith(_INTERNAL_TURN_PREFIXES) + + def _message_content_text(content: Any) -> str: parts: list[str] = [] content_items = content if isinstance(content, list) else [content] @@ -363,6 +479,30 @@ def _parse_json_value(value: Any) -> Any: return value +def _normalize_image_result(result: Any) -> Any: + image_url = _image_url_from_result(result) + if image_url is None: + return result + return {"type": "image", "image_url": image_url} + + +def _image_url_from_result(result: Any) -> str | None: + if isinstance(result, list): + for block in result: + url = _image_url_from_result(block) + if url is not None: + return url + return None + if isinstance(result, dict): + if result.get("type") in {"image", "input_image", "output_image"}: + url = result.get("image_url") + return url if isinstance(url, str) and url.startswith("data:image/") else None + return None + if isinstance(result, ToolOutputImage) and isinstance(result.image_url, str): + return result.image_url if result.image_url.startswith("data:image/") else None + return None + + def _tool_status_from_result(result: Any) -> str: if isinstance(result, dict) and result.get("success") is False: return "failed" diff --git a/strix/interface/tui/messages.py b/strix/interface/tui/messages.py deleted file mode 100644 index 18cd37c1..00000000 --- a/strix/interface/tui/messages.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Message delivery bridge from TUI input to SDK-backed agents.""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Any - - -logger = logging.getLogger(__name__) - - -def send_user_message_to_agent( - *, - coordinator: Any, - loop: asyncio.AbstractEventLoop | None, - live_view: Any, - target_agent_id: str, - message: str, -) -> bool: - if loop is None or loop.is_closed(): - return False - - live_view.record_user_message(target_agent_id, message) - future = asyncio.run_coroutine_threadsafe( - coordinator.send( - target_agent_id, - {"from": "user", "content": message, "type": "instruction"}, - ), - loop, - ) - future.add_done_callback(_log_delivery_failure) - return True - - -def _log_delivery_failure(future: Any) -> None: - try: - delivered = bool(future.result()) - except Exception: - logger.exception("TUI user message delivery failed") - return - if not delivered: - logger.warning("TUI user message was not persisted to the SDK session") diff --git a/strix/interface/tui/renderers/__init__.py b/strix/interface/tui/renderers/__init__.py deleted file mode 100644 index 1535b6f4..00000000 --- a/strix/interface/tui/renderers/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -from . import ( - agents_graph_renderer, - filesystem_renderer, - finish_renderer, - load_skill_renderer, - notes_renderer, - proxy_renderer, - reporting_renderer, - respond_renderer, - shell_renderer, - thinking_renderer, - todo_renderer, - web_search_renderer, -) -from .registry import render_tool_widget - - -__all__ = [ - "agents_graph_renderer", - "filesystem_renderer", - "finish_renderer", - "load_skill_renderer", - "notes_renderer", - "proxy_renderer", - "render_tool_widget", - "reporting_renderer", - "respond_renderer", - "shell_renderer", - "thinking_renderer", - "todo_renderer", - "web_search_renderer", -] diff --git a/strix/interface/tui/renderers/agent_message_renderer.py b/strix/interface/tui/renderers/agent_message_renderer.py deleted file mode 100644 index c4362804..00000000 --- a/strix/interface/tui/renderers/agent_message_renderer.py +++ /dev/null @@ -1,180 +0,0 @@ -import re -from functools import cache -from typing import Any, ClassVar - -from pygments.lexers import get_lexer_by_name, guess_lexer -from pygments.styles import get_style_by_name -from pygments.util import ClassNotFound -from rich.text import Text - - -_BLANK_LINE_RUNS = re.compile(r"\n\s*\n") - - -_HEADER_STYLES = [ - ("###### ", 7, "bold #4ade80"), - ("##### ", 6, "bold #22c55e"), - ("#### ", 5, "bold #16a34a"), - ("### ", 4, "bold #15803d"), - ("## ", 3, "bold #22c55e"), - ("# ", 2, "bold #4ade80"), -] - - -@cache -def _get_style_colors() -> dict[Any, str]: - style = get_style_by_name("native") - return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]} - - -def _get_token_color(token_type: Any) -> str | None: - colors = _get_style_colors() - while token_type: - if token_type in colors: - return colors[token_type] - token_type = token_type.parent - return None - - -def _highlight_code(code: str, language: str | None = None) -> Text: - text = Text() - - try: - lexer = get_lexer_by_name(language) if language else guess_lexer(code) - except ClassNotFound: - text.append(code, style="#d4d4d4") - return text - - for token_type, token_value in lexer.get_tokens(code): - if not token_value: - continue - color = _get_token_color(token_type) - text.append(token_value, style=color) - - return text - - -def _try_parse_header(line: str) -> tuple[str, str] | None: - for prefix, strip_len, style in _HEADER_STYLES: - if line.startswith(prefix): - return (line[strip_len:], style) - return None - - -def _apply_markdown_styles(text: str) -> Text: # noqa: PLR0912 - result = Text() - lines = text.split("\n") - - in_code_block = False - code_block_lang: str | None = None - code_block_lines: list[str] = [] - - for i, line in enumerate(lines): - if i > 0 and not in_code_block: - result.append("\n") - - if line.startswith("```"): - if not in_code_block: - in_code_block = True - code_block_lang = line[3:].strip() or None - code_block_lines = [] - if i > 0: - result.append("\n") - else: - in_code_block = False - code_content = "\n".join(code_block_lines) - if code_content: - result.append_text(_highlight_code(code_content, code_block_lang)) - code_block_lines = [] - code_block_lang = None - continue - - if in_code_block: - code_block_lines.append(line) - continue - - header = _try_parse_header(line) - if header: - result.append(header[0], style=header[1]) - elif line.startswith("> "): - result.append("┃ ", style="#22c55e") - result.append_text(_process_inline_formatting(line[2:])) - elif line.startswith(("- ", "* ")): - result.append("β€’ ", style="#22c55e") - result.append_text(_process_inline_formatting(line[2:])) - elif len(line) > 2 and line[0].isdigit() and line[1:3] in (". ", ") "): - result.append(line[0] + ". ", style="#22c55e") - result.append_text(_process_inline_formatting(line[2:])) - elif line.strip() in ("---", "***", "___"): - result.append("─" * 40, style="#22c55e") - else: - result.append_text(_process_inline_formatting(line)) - - if in_code_block and code_block_lines: - code_content = "\n".join(code_block_lines) - result.append_text(_highlight_code(code_content, code_block_lang)) - - return result - - -def _process_inline_formatting(line: str) -> Text: - result = Text() - i = 0 - n = len(line) - - while i < n: - if i + 1 < n and line[i : i + 2] in ("**", "__"): - marker = line[i : i + 2] - end = line.find(marker, i + 2) - if end != -1: - result.append(line[i + 2 : end], style="bold #4ade80") - i = end + 2 - continue - - if i + 1 < n and line[i : i + 2] == "~~": - end = line.find("~~", i + 2) - if end != -1: - result.append(line[i + 2 : end], style="strike #525252") - i = end + 2 - continue - - if line[i] == "`": - end = line.find("`", i + 1) - if end != -1: - result.append(line[i + 1 : end], style="bold #22c55e on #0a0a0a") - i = end + 1 - continue - - if line[i] in ("*", "_"): - marker = line[i] - if i + 1 < n and line[i + 1] != marker: - end = line.find(marker, i + 1) - if end != -1 and (end + 1 >= n or line[end + 1] != marker): - result.append(line[i + 1 : end], style="italic #86efac") - i = end + 1 - continue - - result.append(line[i]) - i += 1 - - return result - - -class AgentMessageRenderer: - _cache: ClassVar[dict[str, Text]] = {} - - @classmethod - def render_simple(cls, content: str) -> Text: - if not content: - return Text() - cleaned = _BLANK_LINE_RUNS.sub("\n\n", content).strip() - if not cleaned: - return Text() - cached = cls._cache.get(cleaned) - if cached is not None: - return cached.copy() - rendered = _apply_markdown_styles(cleaned) - if len(cls._cache) > 100: - cls._cache.clear() - cls._cache[cleaned] = rendered - return rendered.copy() diff --git a/strix/interface/tui/renderers/agents_graph_renderer.py b/strix/interface/tui/renderers/agents_graph_renderer.py deleted file mode 100644 index de359dc8..00000000 --- a/strix/interface/tui/renderers/agents_graph_renderer.py +++ /dev/null @@ -1,175 +0,0 @@ -from typing import Any, ClassVar - -from rich.text import Text -from textual.widgets import Static - -from .base_renderer import BaseToolRenderer -from .registry import register_tool_renderer - - -@register_tool_renderer -class ViewAgentGraphRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "view_agent_graph" - css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - status = tool_data.get("status", "unknown") - - text = Text() - text.append("β—‡ ", style="#a78bfa") - text.append("viewing agents graph", style="dim") - - css_classes = cls.get_css_classes(status) - return Static(text, classes=css_classes) - - -@register_tool_renderer -class CreateAgentRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "create_agent" - css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - status = tool_data.get("status", "unknown") - - task = args.get("task", "") - name = args.get("name", "Agent") - - text = Text() - text.append("β—ˆ ", style="#a78bfa") - text.append("spawning ", style="dim") - text.append(name, style="bold #a78bfa") - - if task: - text.append("\n ") - text.append(task, style="dim") - - css_classes = cls.get_css_classes(status) - return Static(text, classes=css_classes) - - -@register_tool_renderer -class SendMessageToAgentRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "send_message_to_agent" - css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - status = tool_data.get("status", "unknown") - - message = args.get("message", "") - target_agent_id = args.get("target_agent_id", "") - - text = Text() - text.append("β†’ ", style="#60a5fa") - if target_agent_id: - text.append(f"to {target_agent_id}", style="dim") - else: - text.append("sending message", style="dim") - - if message: - text.append("\n ") - text.append(message, style="dim") - - css_classes = cls.get_css_classes(status) - return Static(text, classes=css_classes) - - -@register_tool_renderer -class AgentFinishRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "agent_finish" - css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - - result_summary = args.get("result_summary", "") - findings = args.get("findings", []) - success = args.get("success", True) - - text = Text() - - if success: - text.append("β—† ", style="#22c55e") - text.append("Agent completed", style="bold #22c55e") - else: - text.append("β—† ", style="#ef4444") - text.append("Agent failed", style="bold #ef4444") - - if result_summary: - text.append("\n ") - text.append(result_summary, style="bold") - - if findings and isinstance(findings, list): - for finding in findings: - text.append("\n β€’ ") - text.append(str(finding), style="dim") - else: - text.append("\n ") - text.append("Completing task...", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) - - -@register_tool_renderer -class WaitForAgentsRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "wait_for_agents" - css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - status = tool_data.get("status", "unknown") - - reason = args.get("reason", "") - - text = Text() - text.append("β—‹ ", style="#6b7280") - text.append("waiting", style="dim") - - if reason: - text.append("\n ") - text.append(reason, style="dim") - - css_classes = cls.get_css_classes(status) - return Static(text, classes=css_classes) - - -@register_tool_renderer -class StopAgentRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "stop_agent" - css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - result = tool_data.get("result") - status = tool_data.get("status", "unknown") - - target_agent_id = args.get("target_agent_id", "") - cascade = args.get("cascade", True) - reason = args.get("reason", "") - - text = Text() - text.append("β—Ό ", style="#ef4444") - text.append("stopping", style="dim") - if target_agent_id: - text.append(f" {target_agent_id}", style="bold #ef4444") - if cascade: - text.append(" + descendants", style="dim italic") - - if reason: - text.append("\n ") - text.append(reason, style="dim") - - if isinstance(result, dict) and result.get("success") is False and result.get("error"): - text.append("\n ") - text.append(str(result["error"]), style="#ef4444") - - css_classes = cls.get_css_classes(status) - return Static(text, classes=css_classes) diff --git a/strix/interface/tui/renderers/base_renderer.py b/strix/interface/tui/renderers/base_renderer.py deleted file mode 100644 index aea25b22..00000000 --- a/strix/interface/tui/renderers/base_renderer.py +++ /dev/null @@ -1,30 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, ClassVar - -from textual.widgets import Static - - -class BaseToolRenderer(ABC): - tool_name: ClassVar[str] = "" - css_classes: ClassVar[list[str]] = ["tool-call"] - - @classmethod - @abstractmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - pass - - @classmethod - def status_icon(cls, status: str) -> tuple[str, str]: - icons = { - "running": ("● In progress...", "#f59e0b"), - "completed": ("βœ“ Done", "#22c55e"), - "failed": ("βœ— Failed", "#dc2626"), - "error": ("βœ— Error", "#dc2626"), - } - return icons.get(status, ("β—‹ Unknown", "dim")) - - @classmethod - def get_css_classes(cls, status: str) -> str: - base_classes = cls.css_classes.copy() - base_classes.append(f"status-{status}") - return " ".join(base_classes) diff --git a/strix/interface/tui/renderers/filesystem_renderer.py b/strix/interface/tui/renderers/filesystem_renderer.py deleted file mode 100644 index 341addcb..00000000 --- a/strix/interface/tui/renderers/filesystem_renderer.py +++ /dev/null @@ -1,266 +0,0 @@ -from __future__ import annotations - -import json -from functools import cache -from typing import Any, ClassVar - -from pygments.lexers import get_lexer_by_name, get_lexer_for_filename -from pygments.styles import get_style_by_name -from pygments.util import ClassNotFound -from rich.text import Text -from textual.widgets import Static - -from .base_renderer import BaseToolRenderer -from .registry import register_tool_renderer - - -_ADD_FILE = "*** Add File: " -_DELETE_FILE = "*** Delete File: " -_UPDATE_FILE = "*** Update File: " -_BEGIN_PATCH = "*** Begin Patch" -_END_PATCH = "*** End Patch" - -_VIEW_IMAGE_ERROR_PREFIXES = ( - "image path ", - "unable to read image", - "manifest path", - "exceeded the allowed size", -) - - -@cache -def _get_style_colors() -> dict[Any, str]: - style = get_style_by_name("native") - return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]} - - -def _get_lexer_for_file(path: str) -> Any: - try: - return get_lexer_for_filename(path) - except ClassNotFound: - return get_lexer_by_name("text") - - -def _get_token_color(token_type: Any) -> str | None: - colors = _get_style_colors() - while token_type: - if token_type in colors: - return colors[token_type] - token_type = token_type.parent - return None - - -def _highlight_code(code: str, path: str) -> Text: - lexer = _get_lexer_for_file(path) - text = Text() - for token_type, token_value in lexer.get_tokens(code): - if not token_value: - continue - color = _get_token_color(token_type) - text.append(token_value, style=color) - return text - - -def _extract_patch_text(args: dict[str, Any]) -> str: - """apply_patch input arrives as either {"patch": text} or raw text in - the "input" field, depending on whether the tool is wrapped as a - chat-completions FunctionTool or routed through as a CustomTool. - """ - raw = args.get("patch") - if isinstance(raw, str): - return raw - if isinstance(raw, dict): - inner = raw.get("patch") - if isinstance(inner, str): - return inner - fallback = args.get("input") if isinstance(args, dict) else None - if isinstance(fallback, str): - return fallback - return "" - - -def _parse_patch_operations( - patch_text: str, -) -> list[tuple[str, str, list[str], list[str]]]: - """Return [(kind, path, old_lines, new_lines), ...] for each file op.""" - ops: list[tuple[str, str, list[str], list[str]]] = [] - current_kind: str | None = None - current_path: str | None = None - old_lines: list[str] = [] - new_lines: list[str] = [] - - def flush() -> None: - nonlocal current_kind, current_path, old_lines, new_lines - if current_kind and current_path is not None: - ops.append((current_kind, current_path, old_lines, new_lines)) - current_kind = None - current_path = None - old_lines = [] - new_lines = [] - - for line in patch_text.splitlines(): - if line in (_BEGIN_PATCH, _END_PATCH): - continue - if line.startswith(_ADD_FILE): - flush() - current_kind = "add" - current_path = line[len(_ADD_FILE) :].strip() - elif line.startswith(_UPDATE_FILE): - flush() - current_kind = "update" - current_path = line[len(_UPDATE_FILE) :].strip() - elif line.startswith(_DELETE_FILE): - flush() - current_kind = "delete" - current_path = line[len(_DELETE_FILE) :].strip() - elif current_kind == "update": - if line.startswith("@@"): - continue - if line.startswith("-") and not line.startswith("---"): - old_lines.append(line[1:]) - elif line.startswith("+") and not line.startswith("+++"): - new_lines.append(line[1:]) - elif current_kind == "add": - if line.startswith("+"): - new_lines.append(line[1:]) - elif line.strip(): - new_lines.append(line) - flush() - return ops - - -_OP_LABEL = { - "add": "create", - "update": "edit", - "delete": "delete", -} - - -def _render_operation(text: Text, kind: str, path: str, old: list[str], new: list[str]) -> None: - label = _OP_LABEL.get(kind, "file") - - text.append("β—‡ ", style="#10b981") - text.append(label, style="dim") - - if path: - path_display = path[-60:] if len(path) > 60 else path - text.append(" ") - text.append(path_display, style="dim") - - if kind == "update": - if old: - highlighted_old = _highlight_code("\n".join(old), path) - for line in highlighted_old.plain.split("\n"): - text.append("\n") - text.append("-", style="#ef4444") - text.append(" ") - text.append(line) - if new: - highlighted_new = _highlight_code("\n".join(new), path) - for line in highlighted_new.plain.split("\n"): - text.append("\n") - text.append("+", style="#22c55e") - text.append(" ") - text.append(line) - elif kind == "add" and new: - text.append("\n") - text.append_text(_highlight_code("\n".join(new), path)) - - -@register_tool_renderer -class ApplyPatchRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "apply_patch" - css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - result = tool_data.get("result") - status = tool_data.get("status", "completed") - - patch_text = _extract_patch_text(args) - ops = _parse_patch_operations(patch_text) - - text = Text() - - if not ops: - text.append("β—‡ ", style="#10b981") - text.append("patch", style="dim") - if isinstance(result, str) and result.strip(): - text.append("\n ") - text.append(result.strip(), style="dim") - elif not result: - text.append(" ") - text.append("Processing...", style="dim") - return Static(text, classes=cls.get_css_classes(status)) - - for i, (kind, path, old, new) in enumerate(ops): - if i > 0: - text.append("\n") - _render_operation(text, kind, path, old, new) - - if status == "failed" and isinstance(result, str) and result.strip(): - text.append("\n ") - text.append(result.strip(), style="#ef4444") - - return Static(text, classes=cls.get_css_classes(status)) - - -def _is_image_success(result: Any) -> bool: - if isinstance(result, dict) and result.get("type") == "image": - return True - if isinstance(result, str): - stripped = result.lstrip() - if stripped.startswith("data:image/"): - return True - try: - obj = json.loads(stripped) - except (TypeError, ValueError): - return False - return isinstance(obj, dict) and obj.get("type") == "image" - return False - - -def _image_error_text(result: Any) -> str | None: - if not isinstance(result, str): - return None - stripped = result.strip() - if not stripped: - return None - lower = stripped.lower() - if lower.startswith(_VIEW_IMAGE_ERROR_PREFIXES) or "not a supported image" in lower: - return stripped - return None - - -@register_tool_renderer -class ViewImageRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "view_image" - css_classes: ClassVar[list[str]] = ["tool-call", "file-edit-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - result = tool_data.get("result") - status = tool_data.get("status", "completed") - - path = str(args.get("path", "")).strip() - - text = Text() - text.append("β—‡ ", style="#10b981") - text.append("view image", style="dim") - - if path: - path_display = path[-60:] if len(path) > 60 else path - text.append(" ") - text.append(path_display, style="dim") - - err = _image_error_text(result) - if err is not None: - text.append("\n ") - text.append(err, style="#ef4444") - elif _is_image_success(result): - text.append(" ") - text.append("βœ“", style="#22c55e") - - return Static(text, classes=cls.get_css_classes(status)) diff --git a/strix/interface/tui/renderers/finish_renderer.py b/strix/interface/tui/renderers/finish_renderer.py deleted file mode 100644 index 62c21288..00000000 --- a/strix/interface/tui/renderers/finish_renderer.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Any, ClassVar - -from rich.text import Text -from textual.widgets import Static - -from .base_renderer import BaseToolRenderer -from .registry import register_tool_renderer - - -FIELD_STYLE = "bold #4ade80" - - -@register_tool_renderer -class FinishScanRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "finish_scan" - css_classes: ClassVar[list[str]] = ["tool-call", "finish-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - - executive_summary = args.get("executive_summary", "") - methodology = args.get("methodology", "") - technical_analysis = args.get("technical_analysis", "") - recommendations = args.get("recommendations", "") - - text = Text() - text.append("β—† ", style="#22c55e") - text.append("Penetration test completed", style="bold #22c55e") - - if executive_summary: - text.append("\n\n") - text.append("Executive Summary", style=FIELD_STYLE) - text.append("\n") - text.append(executive_summary) - - if methodology: - text.append("\n\n") - text.append("Methodology", style=FIELD_STYLE) - text.append("\n") - text.append(methodology) - - if technical_analysis: - text.append("\n\n") - text.append("Technical Analysis", style=FIELD_STYLE) - text.append("\n") - text.append(technical_analysis) - - if recommendations: - text.append("\n\n") - text.append("Recommendations", style=FIELD_STYLE) - text.append("\n") - text.append(recommendations) - - if not (executive_summary or methodology or technical_analysis or recommendations): - text.append("\n ") - text.append("Generating final report...", style="dim") - - padded = Text() - padded.append("\n\n") - padded.append_text(text) - padded.append("\n\n") - - css_classes = cls.get_css_classes("completed") - return Static(padded, classes=css_classes) diff --git a/strix/interface/tui/renderers/load_skill_renderer.py b/strix/interface/tui/renderers/load_skill_renderer.py deleted file mode 100644 index 52cde4be..00000000 --- a/strix/interface/tui/renderers/load_skill_renderer.py +++ /dev/null @@ -1,37 +0,0 @@ -from typing import Any, ClassVar - -from rich.text import Text -from textual.widgets import Static - -from .base_renderer import BaseToolRenderer -from .registry import register_tool_renderer - - -@register_tool_renderer -class LoadSkillRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "load_skill" - css_classes: ClassVar[list[str]] = ["tool-call", "load-skill-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - status = tool_data.get("status", "completed") - - raw_skills = args.get("skills", "") - if isinstance(raw_skills, list): - requested = ", ".join(str(s) for s in raw_skills) - else: - requested = str(raw_skills) - - text = Text() - text.append("β—‡ ", style="#10b981") - text.append("loading skill", style="dim") - - if requested: - text.append(" ") - text.append(requested, style="#10b981") - elif not tool_data.get("result"): - text.append("\n ") - text.append("Loading...", style="dim") - - return Static(text, classes=cls.get_css_classes(status)) diff --git a/strix/interface/tui/renderers/notes_renderer.py b/strix/interface/tui/renderers/notes_renderer.py deleted file mode 100644 index a3e5ba44..00000000 --- a/strix/interface/tui/renderers/notes_renderer.py +++ /dev/null @@ -1,180 +0,0 @@ -from typing import Any, ClassVar - -from rich.text import Text -from textual.widgets import Static - -from .base_renderer import BaseToolRenderer -from .registry import register_tool_renderer - - -def _author_label(note: dict[str, Any]) -> str: - if note.get("by_you"): - return "you" - agent_name = note.get("agent_name") - return str(agent_name).strip() if agent_name else "" - - -@register_tool_renderer -class CreateNoteRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "create_note" - css_classes: ClassVar[list[str]] = ["tool-call", "notes-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - - title = args.get("title", "") - content = args.get("content", "") - category = args.get("category", "general") - - text = Text() - text.append("β—‡ ", style="#fbbf24") - text.append("note", style="dim") - text.append(" ") - text.append(f"({category})", style="dim") - - if title: - text.append("\n ") - text.append(title.strip()) - - if content: - text.append("\n ") - text.append(content.strip(), style="dim") - - if not title and not content: - text.append("\n ") - text.append("Capturing...", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) - - -@register_tool_renderer -class DeleteNoteRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "delete_note" - css_classes: ClassVar[list[str]] = ["tool-call", "notes-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: ARG003 - text = Text() - text.append("β—‡ ", style="#fbbf24") - text.append("note removed", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) - - -@register_tool_renderer -class UpdateNoteRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "update_note" - css_classes: ClassVar[list[str]] = ["tool-call", "notes-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - - title = args.get("title") - content = args.get("content") - - text = Text() - text.append("β—‡ ", style="#fbbf24") - text.append("note updated", style="dim") - - if title: - text.append("\n ") - text.append(title) - - if content: - text.append("\n ") - text.append(content.strip(), style="dim") - - if not title and not content: - text.append("\n ") - text.append("Updating...", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) - - -@register_tool_renderer -class ListNotesRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "list_notes" - css_classes: ClassVar[list[str]] = ["tool-call", "notes-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - result = tool_data.get("result") - - text = Text() - text.append("β—‡ ", style="#fbbf24") - text.append("notes", style="dim") - - if isinstance(result, str) and result.strip(): - text.append("\n ") - text.append(result.strip(), style="dim") - elif result and isinstance(result, dict) and result.get("success"): - count = result.get("total_count", 0) - notes = result.get("notes", []) or [] - - if count == 0: - text.append("\n ") - text.append("No notes", style="dim") - else: - for note in notes: - title = note.get("title", "").strip() or "(untitled)" - category = note.get("category", "general") - note_content = note.get("content", "").strip() - if not note_content: - note_content = note.get("content_preview", "").strip() - - text.append("\n - ") - text.append(title) - text.append(f" ({category})", style="dim") - author = _author_label(note) - if author: - text.append(f" by {author}", style="dim") - - if note_content: - text.append("\n ") - text.append(note_content, style="dim") - else: - text.append("\n ") - text.append("Loading...", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) - - -@register_tool_renderer -class GetNoteRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "get_note" - css_classes: ClassVar[list[str]] = ["tool-call", "notes-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - result = tool_data.get("result") - - text = Text() - text.append("β—‡ ", style="#fbbf24") - text.append("note read", style="dim") - - if result and isinstance(result, dict) and result.get("success"): - note = result.get("note", {}) or {} - title = str(note.get("title", "")).strip() or "(untitled)" - category = note.get("category", "general") - content = str(note.get("content", "")).strip() - text.append("\n ") - text.append(title) - text.append(f" ({category})", style="dim") - author = _author_label(note) - if author: - text.append(f" by {author}", style="dim") - if content: - text.append("\n ") - text.append(content, style="dim") - else: - text.append("\n ") - text.append("Loading...", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) diff --git a/strix/interface/tui/renderers/proxy_renderer.py b/strix/interface/tui/renderers/proxy_renderer.py deleted file mode 100644 index 359b494d..00000000 --- a/strix/interface/tui/renderers/proxy_renderer.py +++ /dev/null @@ -1,536 +0,0 @@ -from typing import Any, ClassVar - -from rich.text import Text -from textual.widgets import Static - -from .base_renderer import BaseToolRenderer -from .registry import register_tool_renderer - - -PROXY_ICON = "<~>" -MAX_REQUESTS_DISPLAY = 20 -MAX_LINE_LENGTH = 200 - - -def _truncate(text: str, max_len: int = 80) -> str: - return text[: max_len - 3] + "..." if len(text) > max_len else text - - -def _sanitize(text: str, max_len: int = 150) -> str: - clean = text.replace("\n", " ").replace("\r", "").replace("\t", " ") - return _truncate(clean, max_len) - - -def _status_style(code: int | None) -> str: - if code is None: - return "dim" - if 200 <= code < 300: - return "#22c55e" # green - if 300 <= code < 400: - return "#eab308" # yellow - if 400 <= code < 500: - return "#f97316" # orange - if code >= 500: - return "#ef4444" # red - return "dim" - - -@register_tool_renderer -class ListRequestsRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "list_requests" - css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915 - args = tool_data.get("args", {}) - result = tool_data.get("result") - status = tool_data.get("status", "running") - - httpql_filter = args.get("httpql_filter") - sort_by = args.get("sort_by") - sort_order = args.get("sort_order") - scope_id = args.get("scope_id") - - text = Text() - text.append(PROXY_ICON, style="dim") - text.append(" listing requests", style="#06b6d4") - - if httpql_filter: - text.append(f" where {_truncate(httpql_filter, 150)}", style="dim italic") - - meta_parts = [] - if sort_by and sort_by != "timestamp": - meta_parts.append(f"by:{sort_by}") - if sort_order and sort_order != "desc": - meta_parts.append(sort_order) - if scope_id and isinstance(scope_id, str): - meta_parts.append(f"scope:{scope_id[:8]}") - if meta_parts: - text.append(f" ({', '.join(meta_parts)})", style="dim") - - if status == "completed" and isinstance(result, dict): - if "error" in result: - text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444") - else: - entries = result.get("entries", []) - page_info = result.get("page_info") or {} - has_more = ( - bool(page_info.get("has_next_page")) if isinstance(page_info, dict) else False - ) - count_suffix = "+" if has_more else "" - text.append(f" [{len(entries)}{count_suffix} found]", style="dim") - - if entries and isinstance(entries, list): - text.append("\n") - for i, entry in enumerate(entries[:MAX_REQUESTS_DISPLAY]): - if not isinstance(entry, dict): - continue - req = entry.get("request") or {} - resp = entry.get("response") or {} - method = req.get("method", "?") if isinstance(req, dict) else "?" - host = req.get("host", "") if isinstance(req, dict) else "" - path = req.get("path", "/") if isinstance(req, dict) else "/" - code = resp.get("status_code") if isinstance(resp, dict) else None - - text.append(" ") - text.append(f"{method:6}", style="#a78bfa") - text.append(f" {_truncate(host + path, 180)}", style="dim") - if code: - text.append(f" {code}", style=_status_style(code)) - - if i < min(len(entries), MAX_REQUESTS_DISPLAY) - 1: - text.append("\n") - - if len(entries) > MAX_REQUESTS_DISPLAY: - text.append("\n") - text.append( - f" ... +{len(entries) - MAX_REQUESTS_DISPLAY} more", - style="dim italic", - ) - - css_classes = cls.get_css_classes(status) - return Static(text, classes=css_classes) - - -@register_tool_renderer -class ViewRequestRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "view_request" - css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915 - args = tool_data.get("args", {}) - result = tool_data.get("result") - status = tool_data.get("status", "running") - - request_id = args.get("request_id", "") - part = args.get("part", "request") - search_pattern = args.get("search_pattern") - - text = Text() - text.append(PROXY_ICON, style="dim") - - action = "searching" if search_pattern else "viewing" - text.append(f" {action} {part}", style="#06b6d4") - - if request_id: - text.append(f" #{request_id}", style="dim") - - if search_pattern: - text.append(f" /{_truncate(search_pattern, 100)}/", style="dim italic") - - if status == "completed" and isinstance(result, dict): - if "error" in result: - text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444") - elif "hits" in result: - hits = result.get("hits", []) - total = result.get("total_hits", len(hits)) - text.append(f" [{total} matches]", style="dim") - - if hits and isinstance(hits, list): - text.append("\n") - for i, m in enumerate(hits[:5]): - if not isinstance(m, dict): - continue - before = m.get("before", "") or "" - match_text = m.get("match", "") or "" - after = m.get("after", "") or "" - - before = before.replace("\n", " ").replace("\r", "")[-100:] - after = after.replace("\n", " ").replace("\r", "")[:100] - - text.append(" ") - - if before: - text.append(f"...{before}", style="dim") - text.append(match_text, style="#22c55e bold") - if after: - text.append(f"{after}...", style="dim") - - if i < min(len(hits), 5) - 1: - text.append("\n") - - if len(hits) > 5: - text.append("\n") - text.append(f" ... +{len(hits) - 5} more matches", style="dim italic") - - elif "content" in result: - page = result.get("page", 1) - total_lines = result.get("total_lines", 0) - has_more = result.get("has_more", False) - content = result.get("content", "") - - text.append(f" [page {page}, {total_lines} lines]", style="dim") - - if content and isinstance(content, str): - lines = content.split("\n")[:15] - text.append("\n") - for i, line in enumerate(lines): - text.append(" ") - text.append(_truncate(line, MAX_LINE_LENGTH), style="dim") - if i < len(lines) - 1: - text.append("\n") - - if has_more or len(content.split("\n")) > 15: - text.append("\n") - text.append(" ... more content available", style="dim italic") - - css_classes = cls.get_css_classes(status) - return Static(text, classes=css_classes) - - -@register_tool_renderer -class RepeatRequestRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "repeat_request" - css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915 - args = tool_data.get("args", {}) - result = tool_data.get("result") - status = tool_data.get("status", "running") - - request_id = args.get("request_id", "") - modifications = args.get("modifications") - - text = Text() - text.append(PROXY_ICON, style="dim") - text.append(" repeating request", style="#06b6d4") - - if request_id: - text.append(f" #{request_id}", style="dim") - - if modifications and isinstance(modifications, dict): - text.append("\n modifications:", style="dim italic") - - if "url" in modifications: - text.append("\n") - text.append(" >> ", style="#3b82f6") - text.append(f"url: {_truncate(str(modifications['url']), 180)}", style="dim") - - if "headers" in modifications and isinstance(modifications["headers"], dict): - for k, v in list(modifications["headers"].items())[:5]: - text.append("\n") - text.append(" >> ", style="#3b82f6") - text.append(f"{k}: {_sanitize(str(v), 150)}", style="dim") - - if "cookies" in modifications and isinstance(modifications["cookies"], dict): - for k, v in list(modifications["cookies"].items())[:5]: - text.append("\n") - text.append(" >> ", style="#3b82f6") - text.append(f"cookie {k}={_sanitize(str(v), 100)}", style="dim") - - if "params" in modifications and isinstance(modifications["params"], dict): - for k, v in list(modifications["params"].items())[:5]: - text.append("\n") - text.append(" >> ", style="#3b82f6") - text.append(f"param {k}={_sanitize(str(v), 100)}", style="dim") - - if "body" in modifications and isinstance(modifications["body"], str): - text.append("\n") - text.append(" >> ", style="#3b82f6") - body_lines = modifications["body"].split("\n")[:4] - for i, line in enumerate(body_lines): - if i > 0: - text.append("\n") - text.append(" ", style="dim") - text.append(_truncate(line, MAX_LINE_LENGTH), style="dim") - if len(modifications["body"].split("\n")) > 4: - text.append(" ...", style="dim italic") - - elif modifications and isinstance(modifications, str): - text.append(f"\n {_truncate(modifications, 200)}", style="dim italic") - - if status == "completed" and isinstance(result, dict): - if not result.get("success", True) and result.get("error"): - text.append(f"\n error: {_sanitize(str(result['error']), 150)}", style="#ef4444") - else: - elapsed_ms = result.get("elapsed_ms") - response = result.get("response") or {} - code = response.get("status_code") if isinstance(response, dict) else None - body = response.get("body", "") if isinstance(response, dict) else "" - body_truncated = ( - bool(response.get("body_truncated")) if isinstance(response, dict) else False - ) - - text.append("\n") - text.append(" << ", style="#22c55e") - if code: - text.append(f"{code}", style=_status_style(code)) - else: - text.append("(no response)", style="dim") - if elapsed_ms: - text.append(f" ({elapsed_ms}ms)", style="dim") - - if body and isinstance(body, str): - lines = body.split("\n")[:5] - for line in lines: - text.append("\n") - text.append(" << ", style="#22c55e") - text.append(_truncate(line, MAX_LINE_LENGTH - 5), style="dim") - - if body_truncated or len(body.split("\n")) > 5: - text.append("\n") - text.append(" ...", style="dim italic") - - css_classes = cls.get_css_classes(status) - return Static(text, classes=css_classes) - - -@register_tool_renderer -class ListSitemapRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "list_sitemap" - css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915 - args = tool_data.get("args", {}) - result = tool_data.get("result") - status = tool_data.get("status", "running") - - parent_id = args.get("parent_id") - scope_id = args.get("scope_id") - depth = args.get("depth") - - text = Text() - text.append(PROXY_ICON, style="dim") - text.append(" listing sitemap", style="#06b6d4") - - if parent_id: - text.append(f" under #{_truncate(str(parent_id), 20)}", style="dim") - - meta_parts = [] - if scope_id and isinstance(scope_id, str): - meta_parts.append(f"scope:{scope_id[:8]}") - if depth and depth != "DIRECT": - meta_parts.append(depth.lower()) - if meta_parts: - text.append(f" ({', '.join(meta_parts)})", style="dim") - - if status == "completed" and isinstance(result, dict): - if "error" in result: - text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444") - else: - total = result.get("total_count", 0) - entries = result.get("entries", []) - - text.append(f" [{total} entries]", style="dim") - - if entries and isinstance(entries, list): - text.append("\n") - for i, entry in enumerate(entries[:MAX_REQUESTS_DISPLAY]): - if not isinstance(entry, dict): - continue - kind = entry.get("kind") or "?" - label = entry.get("label") or "?" - has_children = entry.get("has_descendants", False) - req = entry.get("request") or {} - - kind_style = { - "DOMAIN": "#f59e0b", - "DIRECTORY": "#3b82f6", - "REQUEST": "#22c55e", - }.get(kind, "dim") - - text.append(" ") - kind_abbr = kind[:3] if isinstance(kind, str) else "?" - text.append(f"{kind_abbr:3}", style=kind_style) - text.append(f" {_truncate(label, 150)}", style="dim") - - if req: - method = req.get("method", "") - code = req.get("status_code") - if method: - text.append(f" {method}", style="#a78bfa") - if code: - text.append(f" {code}", style=_status_style(code)) - - if has_children: - text.append(" +", style="dim italic") - - if i < min(len(entries), MAX_REQUESTS_DISPLAY) - 1: - text.append("\n") - - if len(entries) > MAX_REQUESTS_DISPLAY: - text.append("\n") - text.append( - f" ... +{len(entries) - MAX_REQUESTS_DISPLAY} more", style="dim italic" - ) - - css_classes = cls.get_css_classes(status) - return Static(text, classes=css_classes) - - -@register_tool_renderer -class ViewSitemapEntryRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "view_sitemap_entry" - css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912 - args = tool_data.get("args", {}) - result = tool_data.get("result") - status = tool_data.get("status", "running") - - entry_id = args.get("entry_id", "") - - text = Text() - text.append(PROXY_ICON, style="dim") - text.append(" viewing sitemap", style="#06b6d4") - - if entry_id: - text.append(f" #{_truncate(str(entry_id), 20)}", style="dim") - - if status == "completed" and isinstance(result, dict): - if "error" in result: - text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444") - elif "entry" in result: - entry = result.get("entry") or {} - if not isinstance(entry, dict): - entry = {} - kind = entry.get("kind", "") - label = entry.get("label", "") - related = entry.get("related_requests") or {} - related_reqs = related.get("requests", []) if isinstance(related, dict) else [] - total_related = related.get("total_count", 0) if isinstance(related, dict) else 0 - - if kind and label: - text.append(f" {kind}: {_truncate(label, 120)}", style="dim") - - if total_related: - text.append(f" [{total_related} requests]", style="dim") - - if related_reqs and isinstance(related_reqs, list): - text.append("\n") - for i, req in enumerate(related_reqs[:10]): - if not isinstance(req, dict): - continue - method = req.get("method", "?") - path = req.get("path", "/") - code = req.get("status_code") - - text.append(" ") - text.append(f"{method:6}", style="#a78bfa") - text.append(f" {_truncate(path, 180)}", style="dim") - if code: - text.append(f" {code}", style=_status_style(code)) - - if i < min(len(related_reqs), 10) - 1: - text.append("\n") - - if len(related_reqs) > 10: - text.append("\n") - text.append(f" ... +{len(related_reqs) - 10} more", style="dim italic") - - css_classes = cls.get_css_classes(status) - return Static(text, classes=css_classes) - - -@register_tool_renderer -class ScopeRulesRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "scope_rules" - css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915 - args = tool_data.get("args", {}) - result = tool_data.get("result") - status = tool_data.get("status", "running") - - action = args.get("action", "") - scope_name = args.get("scope_name", "") - scope_id = args.get("scope_id", "") - allowlist = args.get("allowlist") - denylist = args.get("denylist") - - text = Text() - text.append(PROXY_ICON, style="dim") - - action_map = { - "get": "getting", - "list": "listing", - "create": "creating", - "update": "updating", - "delete": "deleting", - } - action_text = action_map.get(action, action + "ing" if action else "managing") - text.append(f" {action_text} proxy scope", style="#06b6d4") - - if scope_name: - text.append(f" '{_truncate(scope_name, 50)}'", style="dim italic") - if scope_id and isinstance(scope_id, str): - text.append(f" #{scope_id[:8]}", style="dim") - - if allowlist and isinstance(allowlist, list): - allow_str = ", ".join(_truncate(str(a), 40) for a in allowlist[:4]) - text.append(f"\n allow: {allow_str}", style="dim") - if len(allowlist) > 4: - text.append(f" +{len(allowlist) - 4}", style="dim italic") - if denylist and isinstance(denylist, list): - deny_str = ", ".join(_truncate(str(d), 40) for d in denylist[:4]) - text.append(f"\n deny: {deny_str}", style="dim") - if len(denylist) > 4: - text.append(f" +{len(denylist) - 4}", style="dim italic") - - if status == "completed" and isinstance(result, dict): - if "error" in result: - text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444") - elif "scopes" in result: - scopes = result.get("scopes", []) - text.append(f" [{len(scopes)} scopes]", style="dim") - - if scopes and isinstance(scopes, list): - text.append("\n") - for i, scope in enumerate(scopes[:5]): - if not isinstance(scope, dict): - continue - name = scope.get("name", "?") - allow = scope.get("allowlist") or [] - text.append(" ") - text.append(_truncate(str(name), 40), style="#22c55e") - if allow and isinstance(allow, list): - allow_str = ", ".join(_truncate(str(a), 30) for a in allow[:3]) - text.append(f" {allow_str}", style="dim") - if len(allow) > 3: - text.append(f" +{len(allow) - 3}", style="dim italic") - if i < min(len(scopes), 5) - 1: - text.append("\n") - - elif "scope" in result: - scope = result.get("scope") or {} - if isinstance(scope, dict): - allow = scope.get("allowlist") or [] - deny = scope.get("denylist") or [] - - if allow and isinstance(allow, list): - allow_str = ", ".join(_truncate(str(a), 40) for a in allow[:5]) - text.append(f"\n allow: {allow_str}", style="dim") - if deny and isinstance(deny, list): - deny_str = ", ".join(_truncate(str(d), 40) for d in deny[:5]) - text.append(f"\n deny: {deny_str}", style="dim") - - elif "message" in result: - text.append(f" {result['message']}", style="#22c55e") - - css_classes = cls.get_css_classes(status) - return Static(text, classes=css_classes) diff --git a/strix/interface/tui/renderers/registry.py b/strix/interface/tui/renderers/registry.py deleted file mode 100644 index a9849b2d..00000000 --- a/strix/interface/tui/renderers/registry.py +++ /dev/null @@ -1,71 +0,0 @@ -from typing import Any, ClassVar - -from rich.text import Text -from textual.widgets import Static - -from .base_renderer import BaseToolRenderer - - -class ToolTUIRegistry: - _renderers: ClassVar[dict[str, type[BaseToolRenderer]]] = {} - - @classmethod - def register(cls, renderer_class: type[BaseToolRenderer]) -> None: - if not renderer_class.tool_name: - raise ValueError(f"Renderer {renderer_class.__name__} must define tool_name") - - cls._renderers[renderer_class.tool_name] = renderer_class - - @classmethod - def get_renderer(cls, tool_name: str) -> type[BaseToolRenderer] | None: - return cls._renderers.get(tool_name) - - -def register_tool_renderer(renderer_class: type[BaseToolRenderer]) -> type[BaseToolRenderer]: - ToolTUIRegistry.register(renderer_class) - return renderer_class - - -def get_tool_renderer(tool_name: str) -> type[BaseToolRenderer] | None: - return ToolTUIRegistry.get_renderer(tool_name) - - -def render_tool_widget(tool_data: dict[str, Any]) -> Static: - tool_name = tool_data.get("tool_name", "") - renderer = get_tool_renderer(tool_name) - - if renderer: - return renderer.render(tool_data) - return _render_default_tool_widget(tool_data) - - -def _render_default_tool_widget(tool_data: dict[str, Any]) -> Static: - tool_name = tool_data.get("tool_name", "Unknown Tool") - args = tool_data.get("args", {}) - status = tool_data.get("status", "unknown") - result = tool_data.get("result") - - text = Text() - - text.append("β†’ Using tool ", style="dim") - text.append(tool_name, style="bold blue") - text.append("\n") - - for k, v in list(args.items()): - str_v = str(v) - text.append(" ") - text.append(k, style="dim") - text.append(": ") - text.append(str_v) - text.append("\n") - - if status in ["completed", "failed", "error"] and result is not None: - result_str = str(result) - text.append("Result: ", style="bold") - text.append(result_str) - else: - icon, color = BaseToolRenderer.status_icon(status) - text.append(icon, style=color) - - css_classes = BaseToolRenderer.get_css_classes(status) - return Static(text, classes=css_classes) diff --git a/strix/interface/tui/renderers/reporting_renderer.py b/strix/interface/tui/renderers/reporting_renderer.py deleted file mode 100644 index 49c6f96e..00000000 --- a/strix/interface/tui/renderers/reporting_renderer.py +++ /dev/null @@ -1,547 +0,0 @@ -from functools import cache -from typing import Any, ClassVar - -from pygments.styles import get_style_by_name -from rich.text import Text -from textual.widgets import Static - -from strix.report.writer import parse_fenced_code, resolve_lexer - -from .base_renderer import BaseToolRenderer -from .registry import register_tool_renderer - - -def _coerce_dict(value: Any) -> dict[str, Any]: - if isinstance(value, dict): - return value - return {} - - -def _coerce_list_of_dicts(value: Any) -> list[dict[str, Any]]: - if isinstance(value, list): - return [item for item in value if isinstance(item, dict)] - return [] - - -@cache -def _get_style_colors() -> dict[Any, str]: - style = get_style_by_name("native") - return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]} - - -FIELD_STYLE = "bold #4ade80" -DIM_STYLE = "dim" -FILE_STYLE = "bold #60a5fa" -LINE_STYLE = "#facc15" -LABEL_STYLE = "italic #a1a1aa" -CODE_STYLE = "#e2e8f0" -BEFORE_STYLE = "#ef4444" -AFTER_STYLE = "#22c55e" - - -@register_tool_renderer -class CreateVulnerabilityReportRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "create_vulnerability_report" - css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"] - - SEVERITY_COLORS: ClassVar[dict[str, str]] = { - "critical": "#dc2626", - "high": "#ea580c", - "medium": "#d97706", - "low": "#65a30d", - "info": "#0284c7", - } - - @classmethod - def _get_token_color(cls, token_type: Any) -> str | None: - colors = _get_style_colors() - while token_type: - if token_type in colors: - return colors[token_type] - token_type = token_type.parent - return None - - @classmethod - def _highlight_code(cls, code: str, language: str | None) -> Text: - lexer = resolve_lexer(language, code) - text = Text() - - for token_type, token_value in lexer.get_tokens(code): - if not token_value: - continue - color = cls._get_token_color(token_type) - text.append(token_value, style=color) - - return text - - @classmethod - def _get_cvss_color(cls, cvss_score: float) -> str: - if cvss_score >= 9.0: - return "#dc2626" - if cvss_score >= 7.0: - return "#ea580c" - if cvss_score >= 4.0: - return "#d97706" - if cvss_score >= 0.1: - return "#65a30d" - return "#6b7280" - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915 - args = tool_data.get("args", {}) - result = tool_data.get("result", {}) - - title = args.get("title", "") - description = args.get("description", "") - impact = args.get("impact", "") - target = args.get("target", "") - technical_analysis = args.get("technical_analysis", "") - poc_description = args.get("poc_description", "") - poc_script_code = args.get("poc_script_code", "") - remediation_steps = args.get("remediation_steps", "") - - cvss_breakdown = _coerce_dict(args.get("cvss_breakdown")) - code_locations = _coerce_list_of_dicts(args.get("code_locations")) - - endpoint = args.get("endpoint", "") - method = args.get("method", "") - cve = args.get("cve", "") - cwe = args.get("cwe", "") - - severity = "" - cvss_score = None - if isinstance(result, dict): - severity = result.get("severity", "") - cvss_score = result.get("cvss_score") - - text = Text() - text.append("🐞 ") - text.append("Vulnerability Report", style="bold #ea580c") - - if title: - text.append("\n\n") - text.append("Title: ", style=FIELD_STYLE) - text.append(title) - - if severity: - text.append("\n\n") - text.append("Severity: ", style=FIELD_STYLE) - severity_color = cls.SEVERITY_COLORS.get(severity.lower(), "#6b7280") - text.append(severity.upper(), style=f"bold {severity_color}") - - if cvss_score is not None: - text.append("\n\n") - text.append("CVSS Score: ", style=FIELD_STYLE) - cvss_color = cls._get_cvss_color(cvss_score) - text.append(str(cvss_score), style=f"bold {cvss_color}") - - if target: - text.append("\n\n") - text.append("Target: ", style=FIELD_STYLE) - text.append(target) - - if endpoint: - text.append("\n\n") - text.append("Endpoint: ", style=FIELD_STYLE) - text.append(endpoint) - - if method: - text.append("\n\n") - text.append("Method: ", style=FIELD_STYLE) - text.append(method) - - if cve: - text.append("\n\n") - text.append("CVE: ", style=FIELD_STYLE) - text.append(cve) - - if cwe: - text.append("\n\n") - text.append("CWE: ", style=FIELD_STYLE) - text.append(cwe) - - if cvss_breakdown: - text.append("\n\n") - cvss_parts = [] - for key, prefix in [ - ("attack_vector", "AV"), - ("attack_complexity", "AC"), - ("privileges_required", "PR"), - ("user_interaction", "UI"), - ("scope", "S"), - ("confidentiality", "C"), - ("integrity", "I"), - ("availability", "A"), - ]: - val = cvss_breakdown.get(key) - if val: - cvss_parts.append(f"{prefix}:{val}") - text.append("CVSS Vector: ", style=FIELD_STYLE) - text.append("/".join(cvss_parts), style=DIM_STYLE) - - if description: - text.append("\n\n") - text.append("Description", style=FIELD_STYLE) - text.append("\n") - text.append(description) - - if impact: - text.append("\n\n") - text.append("Impact", style=FIELD_STYLE) - text.append("\n") - text.append(impact) - - if technical_analysis: - text.append("\n\n") - text.append("Technical Analysis", style=FIELD_STYLE) - text.append("\n") - text.append(technical_analysis) - - if code_locations: - text.append("\n\n") - text.append("Code Locations", style=FIELD_STYLE) - for i, loc in enumerate(code_locations): - text.append("\n\n") - text.append(f" Location {i + 1}: ", style=DIM_STYLE) - text.append(loc.get("file", "unknown"), style=FILE_STYLE) - start = loc.get("start_line") - end = loc.get("end_line") - if start is not None: - if end and end != start: - text.append(f":{start}-{end}", style=LINE_STYLE) - else: - text.append(f":{start}", style=LINE_STYLE) - if loc.get("label"): - text.append(f"\n {loc['label']}", style=LABEL_STYLE) - if loc.get("snippet"): - text.append("\n ") - text.append(loc["snippet"], style=CODE_STYLE) - if loc.get("fix_before") or loc.get("fix_after"): - text.append("\n ") - text.append("Fix:", style=DIM_STYLE) - if loc.get("fix_before"): - text.append("\n ") - text.append("- ", style=BEFORE_STYLE) - text.append(loc["fix_before"], style=BEFORE_STYLE) - if loc.get("fix_after"): - text.append("\n ") - text.append("+ ", style=AFTER_STYLE) - text.append(loc["fix_after"], style=AFTER_STYLE) - - if poc_description: - text.append("\n\n") - text.append("PoC Description", style=FIELD_STYLE) - text.append("\n") - text.append(poc_description) - - if poc_script_code: - poc_language, poc_code = parse_fenced_code(poc_script_code) - text.append("\n\n") - text.append("PoC Code", style=FIELD_STYLE) - text.append("\n") - text.append_text(cls._highlight_code(poc_code, poc_language)) - - if remediation_steps: - text.append("\n\n") - text.append("Remediation", style=FIELD_STYLE) - text.append("\n") - text.append(remediation_steps) - - if not title: - text.append("\n ") - text.append("Creating report...", style="dim") - - padded = Text() - padded.append("\n\n") - padded.append_text(text) - padded.append("\n\n") - - css_classes = cls.get_css_classes("completed") - return Static(padded, classes=css_classes) - - -@register_tool_renderer -class CreateDependencyReportRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "create_dependency_report" - css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"] - - SEVERITY_COLORS: ClassVar[dict[str, str]] = { - "critical": "#dc2626", - "high": "#ea580c", - "medium": "#d97706", - "low": "#65a30d", - "info": "#0284c7", - } - - @classmethod - def _get_cvss_color(cls, cvss_score: float) -> str: - if cvss_score >= 9.0: - return "#dc2626" - if cvss_score >= 7.0: - return "#ea580c" - if cvss_score >= 4.0: - return "#d97706" - if cvss_score >= 0.1: - return "#65a30d" - return "#6b7280" - - @classmethod - def _render_unsuccessful(cls, args: dict[str, Any], result: dict[str, Any]) -> Static: - text = Text() - text.append("πŸ“¦ ") - text.append("Dependency (SCA) Report", style="bold #ea580c") - title = args.get("title", "") - if title: - text.append("\n\n") - text.append("Title: ", style=FIELD_STYLE) - text.append(title) - - warning = result.get("warning") - if result.get("success") is False: - errors = result.get("errors") - detail = ( - "; ".join(errors) if isinstance(errors, list) and errors else result.get("error") - ) - label, style = "βœ— Not created: ", "bold #dc2626" - fallback = "Report was not created." - else: - detail = warning - label, style = "⚠ Not persisted: ", "bold #d97706" - fallback = "Report could not be persisted." - text.append("\n\n") - text.append(label, style=style) - text.append(str(detail or fallback)) - - padded = Text() - padded.append("\n\n") - padded.append_text(text) - padded.append("\n\n") - return Static(padded, classes=cls.get_css_classes("failed")) - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915 - args = tool_data.get("args", {}) - result = tool_data.get("result", {}) - - if isinstance(result, dict) and (result.get("success") is False or result.get("warning")): - return cls._render_unsuccessful(args, result) - - title = args.get("title", "") - description = args.get("description", "") - impact = args.get("impact", "") - target = args.get("target", "") - technical_analysis = args.get("technical_analysis", "") - remediation_steps = args.get("remediation_steps", "") - assumptions = args.get("assumptions", "") - - package_name = args.get("package_name", "") - package_ecosystem = args.get("package_ecosystem", "") - installed_version = args.get("installed_version", "") - fixed_version = args.get("fixed_version", "") - cve = args.get("cve", "") - cwe = args.get("cwe", "") - advisory_cvss = args.get("advisory_cvss") - fix_effort = args.get("fix_effort", "") - - severity = "" - if isinstance(result, dict): - severity = result.get("severity", "") - - text = Text() - text.append("πŸ“¦ ") - text.append("Dependency (SCA) Report", style="bold #ea580c") - - if title: - text.append("\n\n") - text.append("Title: ", style=FIELD_STYLE) - text.append(title) - - if severity: - text.append("\n\n") - text.append("Severity: ", style=FIELD_STYLE) - severity_color = cls.SEVERITY_COLORS.get(severity.lower(), "#6b7280") - text.append(severity.upper(), style=f"bold {severity_color}") - - if advisory_cvss is not None: - text.append("\n\n") - text.append("Advisory CVSS: ", style=FIELD_STYLE) - try: - score = float(advisory_cvss) - text.append(str(score), style=f"bold {cls._get_cvss_color(score)}") - except (TypeError, ValueError): - text.append(str(advisory_cvss), style=DIM_STYLE) - - if cve: - text.append("\n\n") - text.append("CVE: ", style=FIELD_STYLE) - text.append(cve) - - if cwe: - text.append("\n\n") - text.append("CWE: ", style=FIELD_STYLE) - text.append(cwe) - - if package_name: - text.append("\n\n") - text.append("Package: ", style=FIELD_STYLE) - text.append(package_name, style=FILE_STYLE) - if package_ecosystem: - text.append(f" ({package_ecosystem})", style=DIM_STYLE) - - if installed_version: - text.append("\n\n") - text.append("Installed: ", style=FIELD_STYLE) - text.append(installed_version, style=BEFORE_STYLE) - if fixed_version: - text.append(" β†’ ", style=DIM_STYLE) - text.append("Fixed: ", style=FIELD_STYLE) - text.append(fixed_version, style=AFTER_STYLE) - - if fix_effort: - text.append("\n\n") - text.append("Fix Effort: ", style=FIELD_STYLE) - text.append(fix_effort) - - if target: - text.append("\n\n") - text.append("Target: ", style=FIELD_STYLE) - text.append(target) - - for label, value in [ - ("Description", description), - ("Impact", impact), - ("Technical Analysis", technical_analysis), - ("Assumptions", assumptions), - ("Remediation", remediation_steps), - ]: - if value: - text.append("\n\n") - text.append(label, style=FIELD_STYLE) - text.append("\n") - text.append(value) - - if not title: - text.append("\n ") - text.append("Creating dependency report...", style="dim") - - padded = Text() - padded.append("\n\n") - padded.append_text(text) - padded.append("\n\n") - - css_classes = cls.get_css_classes("completed") - return Static(padded, classes=css_classes) - - -_LIST_SEVERITY_COLORS = { - "critical": "#dc2626", - "high": "#ea580c", - "medium": "#d97706", - "low": "#65a30d", - "info": "#0284c7", - "none": "#6b7280", -} - - -def _severity_style(severity: Any) -> str: - return _LIST_SEVERITY_COLORS.get(str(severity or "").lower(), "#d97706") - - -def _author_label(report: dict[str, Any]) -> str: - if report.get("by_you"): - return "you" - agent_name = report.get("agent_name") - return str(agent_name).strip() if agent_name else "" - - -@register_tool_renderer -class ListReportsRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "list_reports" - css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - result = _coerce_dict(tool_data.get("result")) - - text = Text() - text.append("β—† ", style="#ef4444") - text.append("reports", style="dim") - - if isinstance(tool_data.get("result"), str) and str(tool_data["result"]).strip(): - text.append("\n ") - text.append(str(tool_data["result"]).strip(), style="dim") - elif result.get("success"): - total = result.get("total_count", 0) - reports = _coerce_list_of_dicts(result.get("reports")) - counts = _coerce_dict(result.get("severity_counts")) - - text.append(f" ({total})", style="dim") - for sev, count in counts.items(): - text.append(" ") - text.append(f"{sev} {count}", style=_severity_style(sev)) - - if not reports: - text.append("\n ") - text.append("No reports filed yet", style="dim") - else: - for report in reports: - rid = str(report.get("id", "")).strip() - title = str(report.get("title", "")).strip() or "(untitled)" - severity = str(report.get("severity", "")).strip() - text.append("\n - ") - if severity: - text.append(severity.upper(), style=f"bold {_severity_style(severity)}") - text.append(" ") - if rid: - text.append(f"{rid} ", style="dim") - text.append(title) - author = _author_label(report) - if author: - text.append(f" ({author})", style="dim") - else: - text.append("\n ") - text.append("Loading...", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) - - -@register_tool_renderer -class GetReportRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "get_report" - css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - result = _coerce_dict(tool_data.get("result")) - - text = Text() - text.append("β—† ", style="#ef4444") - text.append("report read", style="dim") - - report = _coerce_dict(result.get("report")) if result.get("success") else {} - if report: - rid = str(report.get("id", "")).strip() - title = str(report.get("title", "")).strip() or "(untitled)" - severity = str(report.get("severity", "")).strip() - text.append("\n ") - if severity: - text.append(severity.upper(), style=f"bold {_severity_style(severity)}") - text.append(" ") - if rid: - text.append(f"{rid} ", style="dim") - text.append(title) - author = _author_label(report) - if author: - text.append(f" ({author})", style="dim") - target = str(report.get("target", "")).strip() - if target: - text.append("\n ") - text.append(target, style="dim") - else: - text.append("\n ") - detail = result.get("error") if result.get("success") is False else None - text.append(str(detail) if detail else "Loading...", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) diff --git a/strix/interface/tui/renderers/respond_renderer.py b/strix/interface/tui/renderers/respond_renderer.py deleted file mode 100644 index ed80c08d..00000000 --- a/strix/interface/tui/renderers/respond_renderer.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import Any, ClassVar - -from rich.text import Text -from textual.widgets import Static - -from .agent_message_renderer import AgentMessageRenderer -from .base_renderer import BaseToolRenderer -from .registry import register_tool_renderer - - -@register_tool_renderer -class RespondToUserRenderer(BaseToolRenderer): - """Render a reply as the agent's own prose, not as a tool call. - - ``respond_to_user`` carries the message the user is meant to read, so it - gets the same markdown treatment as a plain assistant turn. - """ - - tool_name: ClassVar[str] = "respond_to_user" - css_classes: ClassVar[list[str]] = ["tool-call", "respond-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - message = args.get("message", "") - - text = Text() - if message: - text.append_text(AgentMessageRenderer.render_simple(message)) - text.append("\n\n") - text.append("β—‹ ", style="#6b7280") - text.append("waiting for your reply", style="dim") - - css_classes = cls.get_css_classes(tool_data.get("status", "unknown")) - return Static(text, classes=css_classes) diff --git a/strix/interface/tui/renderers/shell_renderer.py b/strix/interface/tui/renderers/shell_renderer.py deleted file mode 100644 index 22a6ec13..00000000 --- a/strix/interface/tui/renderers/shell_renderer.py +++ /dev/null @@ -1,266 +0,0 @@ -import re -from functools import cache -from typing import Any, ClassVar - -from pygments.lexers import get_lexer_by_name -from pygments.styles import get_style_by_name -from rich.text import Text -from textual.widgets import Static - -from .base_renderer import BaseToolRenderer -from .registry import register_tool_renderer - - -MAX_OUTPUT_LINES = 50 -MAX_LINE_LENGTH = 200 - -STRIP_PATTERNS = [ - r"^Chunk ID: [0-9a-f]+\s*$", - r"^Wall time: [\d.]+ seconds\s*$", - r"^Process exited with code -?\d+\s*$", - r"^Process running with session ID \d+\s*$", - r"^Original token count: \d+\s*$", -] - -_EXIT_RE = re.compile(r"Process exited with code (-?\d+)") -_SESSION_RE = re.compile(r"Process running with session ID (\d+)") -_OUTPUT_HEADER = "\nOutput:\n" - -_CONTROL_BYTES_TO_DROP = dict.fromkeys( - [b for b in range(0x20) if b not in (0x09, 0x0A)] + [0x7F], - None, -) - - -@cache -def _get_style_colors() -> dict[Any, str]: - style = get_style_by_name("native") - return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]} - - -def _parse_sdk_shell_result(result: Any) -> dict[str, Any]: - """Translate the SDK's terminal-output string into the dict shape the - renderer's `_append_output` helper expects. - - The SDK returns a header-prefixed string ending with `Output:\\n`. - We extract `content`, `exit_code`, and `session_id`; anything else (or a - non-string result) flows through unchanged so renderers can handle errors. - """ - if isinstance(result, dict): - return result - if not isinstance(result, str): - return {"content": "" if result is None else str(result)} - - exit_match = _EXIT_RE.search(result) - session_match = _SESSION_RE.search(result) - idx = result.find(_OUTPUT_HEADER) - content = result[idx + len(_OUTPUT_HEADER) :] if idx >= 0 else result - - parsed: dict[str, Any] = {"content": content} - if exit_match: - parsed["exit_code"] = int(exit_match.group(1)) - if session_match: - parsed["session_id"] = int(session_match.group(1)) - return parsed - - -def _truncate_line(line: str) -> str: - if len(line) > MAX_LINE_LENGTH: - return line[: MAX_LINE_LENGTH - 3] + "..." - return line - - -def _clean_output(output: str) -> str: - cleaned: str = Text.from_ansi(output).plain.translate(_CONTROL_BYTES_TO_DROP) - for pattern in STRIP_PATTERNS: - cleaned = re.sub(pattern, "", cleaned, flags=re.MULTILINE) - - if cleaned.strip(): - lines = cleaned.splitlines() - filtered_lines: list[str] = [] - for line in lines: - if not filtered_lines and not line.strip(): - continue - if line.strip() == "Output:": - continue - filtered_lines.append(line) - while filtered_lines and not filtered_lines[-1].strip(): - filtered_lines.pop() - cleaned = "\n".join(filtered_lines) - - return cleaned.strip() - - -def _format_output(output: str) -> Text: - text = Text() - lines = output.splitlines() - total_lines = len(lines) - - head_count = MAX_OUTPUT_LINES // 2 - tail_count = MAX_OUTPUT_LINES - head_count - 1 - - if total_lines <= MAX_OUTPUT_LINES: - display_lines = lines - truncated = False - hidden_count = 0 - else: - display_lines = lines[:head_count] - truncated = True - hidden_count = total_lines - head_count - tail_count - - for i, line in enumerate(display_lines): - text.append(" ") - text.append(_truncate_line(line), style="dim") - if i < len(display_lines) - 1 or truncated: - text.append("\n") - - if truncated: - text.append(f" ... {hidden_count} lines truncated ...", style="dim italic") - text.append("\n") - tail_lines = lines[-tail_count:] - for i, line in enumerate(tail_lines): - text.append(" ") - text.append(_truncate_line(line), style="dim") - if i < len(tail_lines) - 1: - text.append("\n") - - return text - - -def _get_token_color(token_type: Any) -> str | None: - colors = _get_style_colors() - while token_type: - if token_type in colors: - return colors[token_type] - token_type = token_type.parent - return None - - -def _highlight_bash(code: str) -> Text: - lexer = get_lexer_by_name("bash") - text = Text() - for token_type, token_value in lexer.get_tokens(code): - if not token_value: - continue - color = _get_token_color(token_type) - text.append(token_value, style=color) - return text - - -def _append_output(text: Text, parsed: dict[str, Any], tool_status: str) -> None: - raw_output = parsed.get("content", "") or "" - output = _clean_output(raw_output) if isinstance(raw_output, str) else "" - exit_code = parsed.get("exit_code") - - if tool_status == "running": - if output: - text.append("\n") - text.append_text(_format_output(output)) - return - - if not output: - if exit_code is not None and exit_code != 0: - text.append("\n") - text.append(f" exit {exit_code}", style="dim #ef4444") - return - - text.append("\n") - text.append_text(_format_output(output)) - - if exit_code is not None and exit_code != 0: - text.append("\n") - text.append(f" exit {exit_code}", style="dim #ef4444") - - -def _build_terminal_content( - *, - prompt: str, - prompt_style: str, - command: str, - parsed_result: dict[str, Any] | None, - tool_status: str, - meta: str | None = None, -) -> Text: - text = Text() - text.append(">_", style="dim") - text.append(" ") - - if not command.strip(): - text.append("getting logs...", style="dim") - else: - text.append(prompt, style=prompt_style) - text.append(" ") - text.append_text(_highlight_bash(command)) - - if meta: - text.append(f" {meta}", style="dim") - - if parsed_result is not None: - _append_output(text, parsed_result, tool_status) - - return text - - -@register_tool_renderer -class ExecCommandRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "exec_command" - css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - status = tool_data.get("status", "unknown") - result = tool_data.get("result") - - cmd = str(args.get("cmd", "")) - workdir = args.get("workdir") - tty = bool(args.get("tty")) - - meta_parts: list[str] = [] - if workdir: - meta_parts.append(f"cwd:{workdir}") - if tty: - meta_parts.append("tty") - meta = ", ".join(meta_parts) if meta_parts else None - - parsed = _parse_sdk_shell_result(result) if result is not None else None - - content = _build_terminal_content( - prompt="$", - prompt_style="#22c55e", - command=cmd, - parsed_result=parsed, - tool_status=status, - meta=meta, - ) - - return Static(content, classes=cls.get_css_classes(status)) - - -@register_tool_renderer -class WriteStdinRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "write_stdin" - css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - status = tool_data.get("status", "unknown") - result = tool_data.get("result") - - chars = str(args.get("chars", "")) - session_id = args.get("session_id") - meta = f"session #{session_id}" if session_id is not None else None - - parsed = _parse_sdk_shell_result(result) if result is not None else None - - content = _build_terminal_content( - prompt=">>>", - prompt_style="#3b82f6", - command=chars, - parsed_result=parsed, - tool_status=status, - meta=meta, - ) - - return Static(content, classes=cls.get_css_classes(status)) diff --git a/strix/interface/tui/renderers/thinking_renderer.py b/strix/interface/tui/renderers/thinking_renderer.py deleted file mode 100644 index 598bdf33..00000000 --- a/strix/interface/tui/renderers/thinking_renderer.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Any, ClassVar - -from rich.text import Text -from textual.widgets import Static - -from .base_renderer import BaseToolRenderer -from .registry import register_tool_renderer - - -@register_tool_renderer -class ThinkRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "think" - css_classes: ClassVar[list[str]] = ["tool-call", "thinking-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - thought = args.get("thought", "") - - text = Text() - text.append("🧠 ") - text.append("Thinking", style="bold #a855f7") - text.append("\n ") - - if thought: - text.append(thought, style="italic dim") - else: - text.append("Thinking...", style="italic dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) diff --git a/strix/interface/tui/renderers/todo_renderer.py b/strix/interface/tui/renderers/todo_renderer.py deleted file mode 100644 index d166864b..00000000 --- a/strix/interface/tui/renderers/todo_renderer.py +++ /dev/null @@ -1,225 +0,0 @@ -from typing import Any, ClassVar - -from rich.text import Text -from textual.widgets import Static - -from .base_renderer import BaseToolRenderer -from .registry import register_tool_renderer - - -STATUS_MARKERS: dict[str, str] = { - "pending": "[ ]", - "in_progress": "[~]", - "done": "[β€’]", -} - - -def _format_todo_lines(text: Text, result: dict[str, Any]) -> None: - todos = result.get("todos") - if not isinstance(todos, list) or not todos: - text.append("\n ") - text.append("No todos", style="dim") - return - - for todo in todos: - status = todo.get("status", "pending") - marker = STATUS_MARKERS.get(status, STATUS_MARKERS["pending"]) - - title = todo.get("title", "").strip() or "(untitled)" - - text.append("\n ") - text.append(marker) - text.append(" ") - - if status == "done": - text.append(title, style="dim strike") - elif status == "in_progress": - text.append(title, style="italic") - else: - text.append(title) - - -@register_tool_renderer -class CreateTodoRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "create_todo" - css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - result = tool_data.get("result") - - text = Text() - text.append("πŸ“‹ ") - text.append("Todo", style="bold #a78bfa") - - if isinstance(result, str) and result.strip(): - text.append("\n ") - text.append(result.strip(), style="dim") - elif result and isinstance(result, dict): - if result.get("success"): - _format_todo_lines(text, result) - else: - error = result.get("error", "Failed to create todo") - text.append("\n ") - text.append(error, style="#ef4444") - else: - text.append("\n ") - text.append("Creating...", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) - - -@register_tool_renderer -class ListTodosRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "list_todos" - css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - result = tool_data.get("result") - - text = Text() - text.append("πŸ“‹ ") - text.append("Todos", style="bold #a78bfa") - - if isinstance(result, str) and result.strip(): - text.append("\n ") - text.append(result.strip(), style="dim") - elif result and isinstance(result, dict): - if result.get("success"): - _format_todo_lines(text, result) - else: - error = result.get("error", "Unable to list todos") - text.append("\n ") - text.append(error, style="#ef4444") - else: - text.append("\n ") - text.append("Loading...", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) - - -@register_tool_renderer -class UpdateTodoRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "update_todo" - css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - result = tool_data.get("result") - - text = Text() - text.append("πŸ“‹ ") - text.append("Todo Updated", style="bold #a78bfa") - - if isinstance(result, str) and result.strip(): - text.append("\n ") - text.append(result.strip(), style="dim") - elif result and isinstance(result, dict): - if result.get("success"): - _format_todo_lines(text, result) - else: - error = result.get("error", "Failed to update todo") - text.append("\n ") - text.append(error, style="#ef4444") - else: - text.append("\n ") - text.append("Updating...", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) - - -@register_tool_renderer -class MarkTodoDoneRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "mark_todo_done" - css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - result = tool_data.get("result") - - text = Text() - text.append("πŸ“‹ ") - text.append("Todo Completed", style="bold #a78bfa") - - if isinstance(result, str) and result.strip(): - text.append("\n ") - text.append(result.strip(), style="dim") - elif result and isinstance(result, dict): - if result.get("success"): - _format_todo_lines(text, result) - else: - error = result.get("error", "Failed to mark todo done") - text.append("\n ") - text.append(error, style="#ef4444") - else: - text.append("\n ") - text.append("Marking done...", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) - - -@register_tool_renderer -class MarkTodoPendingRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "mark_todo_pending" - css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - result = tool_data.get("result") - - text = Text() - text.append("πŸ“‹ ") - text.append("Todo Reopened", style="bold #f59e0b") - - if isinstance(result, str) and result.strip(): - text.append("\n ") - text.append(result.strip(), style="dim") - elif result and isinstance(result, dict): - if result.get("success"): - _format_todo_lines(text, result) - else: - error = result.get("error", "Failed to reopen todo") - text.append("\n ") - text.append(error, style="#ef4444") - else: - text.append("\n ") - text.append("Reopening...", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) - - -@register_tool_renderer -class DeleteTodoRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "delete_todo" - css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - result = tool_data.get("result") - - text = Text() - text.append("πŸ“‹ ") - text.append("Todo Removed", style="bold #94a3b8") - - if isinstance(result, str) and result.strip(): - text.append("\n ") - text.append(result.strip(), style="dim") - elif result and isinstance(result, dict): - if result.get("success"): - _format_todo_lines(text, result) - else: - error = result.get("error", "Failed to remove todo") - text.append("\n ") - text.append(error, style="#ef4444") - else: - text.append("\n ") - text.append("Removing...", style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) diff --git a/strix/interface/tui/renderers/user_message_renderer.py b/strix/interface/tui/renderers/user_message_renderer.py deleted file mode 100644 index ea742ceb..00000000 --- a/strix/interface/tui/renderers/user_message_renderer.py +++ /dev/null @@ -1,29 +0,0 @@ -from rich.text import Text - - -class UserMessageRenderer: - @classmethod - def render_simple(cls, content: str) -> Text: - if not content: - return Text() - - return cls._format_user_message(content) - - @classmethod - def _format_user_message(cls, content: str) -> Text: - text = Text() - - text.append("▍", style="#3b82f6") - text.append(" ") - text.append("You:", style="bold") - text.append("\n") - - lines = content.split("\n") - for i, line in enumerate(lines): - if i > 0: - text.append("\n") - text.append("▍", style="#3b82f6") - text.append(" ") - text.append(line) - - return text diff --git a/strix/interface/tui/renderers/web_search_renderer.py b/strix/interface/tui/renderers/web_search_renderer.py deleted file mode 100644 index 4bd20f78..00000000 --- a/strix/interface/tui/renderers/web_search_renderer.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Any, ClassVar - -from rich.text import Text -from textual.widgets import Static - -from .base_renderer import BaseToolRenderer -from .registry import register_tool_renderer - - -@register_tool_renderer -class WebSearchRenderer(BaseToolRenderer): - tool_name: ClassVar[str] = "web_search" - css_classes: ClassVar[list[str]] = ["tool-call", "web-search-tool"] - - @classmethod - def render(cls, tool_data: dict[str, Any]) -> Static: - args = tool_data.get("args", {}) - query = args.get("query", "") - - text = Text() - text.append("🌐 ") - text.append("Searching the web...", style="bold #60a5fa") - - if query: - text.append("\n ") - text.append(query, style="dim") - - css_classes = cls.get_css_classes("completed") - return Static(text, classes=css_classes) diff --git a/strix/interface/tui/runtime.py b/strix/interface/tui/runtime.py new file mode 100644 index 00000000..e056d0bb --- /dev/null +++ b/strix/interface/tui/runtime.py @@ -0,0 +1,392 @@ +"""Launch and supervise the Bubble Tea TUI.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import os +import shutil +import sys +from copy import deepcopy +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from strix.config import load_settings, persist_current +from strix.core.agents import AgentCoordinator +from strix.core.hooks import BudgetExceededError +from strix.core.runner import run_strix_scan +from strix.interface.scan_setup import ( + build_targets_info, + preflight_model_connection, + prepare_run, + telemetry_start, +) +from strix.interface.tui.backend import TuiBackendServer, TuiController +from strix.interface.tui.backend.live_view import TuiLiveView +from strix.interface.tui.sidecar import ( + check_return_code, + child_environment, + launch_tui_process, + package_version, + terminate_process, + tui_executable, + tui_source_dir, + wait_process, +) +from strix.report.state import ReportState, set_global_report_state +from strix.utils.resource_paths import get_strix_resource_path + + +if TYPE_CHECKING: + import argparse + import socket + import subprocess + +logger = logging.getLogger(__name__) + + +class GoTuiPreActivationError(RuntimeError): + """A sidecar failure raised before the Go TUI activates.""" + + +class GoTuiRuntime: + def __init__(self, args: argparse.Namespace) -> None: + self.args = args + self.live_view = TuiLiveView() + self.coordinator = AgentCoordinator() + self.report_state: ReportState | None = None + self.scan_config: dict[str, Any] = {} + self.scan_task: asyncio.Task[None] | None = None + self.scan_error: BaseException | None = None + self._last_sync_fingerprint = "" + self._error_noted_agents: set[str] = set() + self.controller = TuiController( + args, + live_view=self.live_view, + coordinator=self.coordinator, + on_start=self.start_from_setup, + on_quit=self.quit, + ) + self.server = TuiBackendServer(self.controller) + + def init_run_state(self) -> None: + self.scan_config = { + "scan_id": self.args.run_name, + "targets": self.args.targets_info, + "user_instructions": self.args.instruction or "", + "run_name": self.args.run_name, + "diff_scope": self.args.diff_scope, + "scan_mode": self.args.scan_mode, + "non_interactive": False, + "local_sources": self.args.local_sources or [], + "scope_mode": self.args.scope_mode, + "diff_base": self.args.diff_base, + "resume_instruction": self.args.user_explicit_instruction or "", + "workspace_mount": getattr(self.args, "workspace_mount", None) or "", + "workspace_subdir": getattr(self.args, "workspace_subdir", None) or "", + } + self.report_state = ReportState(self.scan_config["run_name"]) + self.report_state.hydrate_from_run_dir() + self.report_state.set_scan_config(self.scan_config) + self.report_state.save_run_data() + set_global_report_state(self.report_state) + self.live_view.hydrate_from_run_dir(self.report_state.get_run_dir()) + self.controller.set_runtime( + report_state=self.report_state, + scan_loop=asyncio.get_running_loop(), + ) + self.report_state.vulnerability_found_callback = lambda _report: ( + self.controller.notify_changed() + ) + self.controller.notify_changed() + + async def start_from_setup(self, verify: bool = True) -> None: + candidate = deepcopy(self.args) + candidate.scan_mode = self.controller.scan_mode + candidate.instruction = self.controller.instruction + # Held apart from instruction, which prepare_run prefixes with the + # diff-scope preamble, so the transcript can show what was typed. + candidate.user_instruction = self.controller.instruction or None + candidate.max_budget_usd = self.controller.max_budget_usd + candidate.max_turns = self.controller.max_turns + candidate.scope_mode = self.controller.scope_mode + candidate.diff_base = self.controller.diff_base + existing_targets = [ + str(target["original"]) + for target in candidate.targets_info + if isinstance(target, dict) and target.get("original") + ] + targets_changed = self.controller.targets != existing_targets + model = (load_settings().llm.model or "").strip() + # A bare prompt launches optimistically: it skips the network preflight + # and lets any model error surface once the agent starts, like a coding + # agent. A named target keeps the upfront check. + if verify: + try: + await preflight_model_connection(model) + except Exception as exc: + logger.exception("Go TUI setup model preflight failed") + raise RuntimeError(f"Model connection failed: {exc}") from exc + # A confirmed target-less launch mounts the working directory for the + # agent to work in, without making it a scan target. + candidate.workspace_mount = self.controller.workspace_mount + if targets_changed: + # Rebuild the full typed set so path canonicalization and local + # deduplication match the CLI. + candidate.target = list(self.controller.targets) + candidate.target_list = [] + build_targets_info(candidate) + prepare_run(candidate) + telemetry_start(candidate) + + vars(self.args).update(vars(candidate)) + self.init_run_state() + self.start_scan() + + async def prepare_and_start(self) -> None: + """Prepare a directly-launched scan once the TUI is on screen. + + The model round trip and run preparation run here rather than before + launch so the interface appears immediately. + """ + model = (load_settings().llm.model or "").strip() + try: + await preflight_model_connection(model) + persist_current() + prepare_run(self.args) + telemetry_start(self.args) + except Exception as exc: + logger.exception("Go TUI scan preparation failed") + self.controller.fail_preparation(str(exc)) + return + self.controller.scan_state = "running" + self.init_run_state() + self.start_scan() + + def start_scan(self) -> None: + if self.scan_task is None: + self.scan_task = asyncio.create_task(self._run_scan()) + + async def _run_scan(self) -> None: + image = str(load_settings().runtime.image or "strix-sandbox:latest") + try: + await run_strix_scan( + scan_config=self.scan_config, + scan_id=self.scan_config["run_name"], + image=image, + local_sources=self.args.local_sources or [], + coordinator=self.coordinator, + interactive=True, + max_turns=self.args.max_turns, + max_budget_usd=self.args.max_budget_usd, + event_sink=self.capture_event, + ) + await self._sync_agent_state() + if self.controller.scan_state == "running": + self.controller.scan_state = "stopped" + except (asyncio.CancelledError, BudgetExceededError): + report_status = ( + self.report_state.run_record.get("status") + if self.report_state is not None + else None + ) + self.controller.scan_state = "completed" if report_status == "completed" else "stopped" + except Exception as exc: + logger.exception("Go TUI scan failed") + self.scan_error = exc + self.controller.error = str(exc) + self.controller.scan_state = "failed" + finally: + with contextlib.suppress(Exception): + await self._sync_agent_state() + self.controller.notify_changed() + + def capture_event(self, agent_id: str, event: Any) -> None: + self.live_view.ingest_sdk_event(agent_id, event) + self.controller.notify_changed() + + async def _sync_agent_state(self) -> bool: + parent_of, statuses, names, errors = await self.coordinator.graph_snapshot() + changed = False + for agent_id, status in statuses.items(): + error = errors.get(agent_id) + changed = ( + self.live_view.upsert_agent( + agent_id, + name=names.get(agent_id, agent_id), + parent_id=parent_of.get(agent_id), + status=str(status), + error_message=error, + ) + or changed + ) + if status in {"failed", "crashed"} and error: + if agent_id not in self._error_noted_agents: + self._error_noted_agents.add(agent_id) + self.live_view.record_agent_error(agent_id, error) + changed = True + else: + self._error_noted_agents.discard(agent_id) + + # The user's opening message waits for the root agent to exist, which is + # the first thing this sync learns about. + changed = self.live_view.flush_user_instruction() or changed + + roots = [agent_id for agent_id, parent_id in parent_of.items() if parent_id is None] + root_id = roots[0] if roots else None + root_status = statuses.get(root_id) if root_id is not None else None + report_status = ( + self.report_state.run_record.get("status") if self.report_state is not None else None + ) + scan_state = self.controller.scan_state + if root_status in {"failed", "crashed"}: + scan_state = "failed" + if root_id is not None and errors.get(root_id): + self.controller.error = errors[root_id] + elif scan_state != "failed": + if report_status == "completed": + scan_state = "completed" + elif root_status == "stopped": + scan_state = "stopped" + elif root_status == "completed": + scan_state = "failed" + self.controller.error = "Scan ended without a completed report" + if scan_state != self.controller.scan_state: + self.controller.scan_state = scan_state + changed = True + return changed + + def _runtime_sync_fingerprint(self) -> str: + usage: dict[str, Any] = {} + vulnerabilities: list[object] = [] + if self.report_state is not None: + usage = dict(self.report_state.get_total_llm_usage()) + vulnerabilities = [ + report.get("id", index) if isinstance(report, dict) else index + for index, report in enumerate(self.report_state.vulnerability_reports) + ] + return json.dumps( + { + "scan_state": self.controller.scan_state, + "usage": usage, + "vulnerabilities": vulnerabilities, + }, + default=str, + sort_keys=True, + separators=(",", ":"), + ) + + async def sync_state(self) -> None: + while True: + if self.scan_task is not None and not self.scan_task.done(): + try: + changed = await self._sync_agent_state() + except Exception as exc: + logger.exception("Go TUI agent-state sync failed") + self.controller.error = f"Agent-state sync failed: {exc}" + changed = True + fingerprint = self._runtime_sync_fingerprint() + if fingerprint != self._last_sync_fingerprint: + self._last_sync_fingerprint = fingerprint + changed = True + if changed: + self.controller.notify_changed() + await asyncio.sleep(0.5) + + async def quit(self) -> None: + self.controller.close_viewer() + self.coordinator.mark_shutting_down() + scan_task = self.scan_task + if scan_task is not None: + if not scan_task.done(): + scan_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await scan_task + + @staticmethod + def binary_command() -> list[str]: + source = tui_source_dir() + # A checkout may also contain a stale wheel/build sidecar. Running the + # current source is the deterministic development choice. + if (source / "go.mod").is_file() and shutil.which("go"): + return ["go", "run", "./cmd/strix-tui"] + packaged = get_strix_resource_path("bin", tui_executable()) + if packaged.is_file(): + return [str(packaged)] + raise RuntimeError( + "Bubble Tea TUI binary not found. Reinstall Strix from an official platform wheel." + ) + + @staticmethod + async def _cancel_tasks(*tasks: asyncio.Task[None] | None) -> None: + for task in tasks: + if task is None: + continue + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + async def run(self) -> None: + # Redirect the process's sys.stdout/sys.stderr while the TUI runs so + # logging handlers created during the scan never paint over the Go + # TUI's alt screen. The child still inherits the real terminal fds; + # only the Python-level bindings change. + original_stdout = sys.stdout + original_stderr = sys.stderr + output_sink = Path(os.devnull).open("a", buffering=1) # noqa: SIM115 + sys.stdout = output_sink + sys.stderr = output_sink + backend_socket: socket.socket | None = None + sync_task: asyncio.Task[None] | None = None + prepare_task: asyncio.Task[None] | None = None + process: asyncio.subprocess.Process | subprocess.Popen[bytes] | None = None + try: + env = child_environment() + env["STRIX_VERSION"] = package_version() + command = self.binary_command() + cwd = str(tui_source_dir()) if command[:2] == ["go", "run"] else None + if cwd is not None: + # go run compiles the sidecar when the build cache is cold, so + # tell the terminal why nothing is on screen yet. + print( + "\x1b[2mCompiling the TUI from source (cached after the first run)...\x1b[0m", + file=original_stdout, + flush=True, + ) + process, backend_socket = await launch_tui_process(command, env, cwd) + await self.server.start(backend_socket) + if not self.controller.setup_mode: + self.controller.begin_preparation() + prepare_task = asyncio.create_task(self.prepare_and_start()) + sync_task = asyncio.create_task(self.sync_state()) + return_code = await wait_process(process) + check_return_code(return_code) + except Exception as exc: + await terminate_process(process) + if not self.server.activated: + raise GoTuiPreActivationError(str(exc)) from exc + raise + except BaseException: + await terminate_process(process) + raise + finally: + try: + if backend_socket is not None: + backend_socket.close() + await self._cancel_tasks(prepare_task, sync_task) + await self.quit() + await self.server.close() + finally: + sys.stdout = original_stdout + sys.stderr = original_stderr + output_sink.close() + # Mirror run_tui: surface the captured scan failure once the app has + # exited cleanly so the CLI reports it instead of exiting 0. + if self.scan_error is not None: + raise self.scan_error + + +async def run_go_tui(args: argparse.Namespace) -> None: + await GoTuiRuntime(args).run() diff --git a/strix/interface/tui/sidecar.py b/strix/interface/tui/sidecar.py new file mode 100644 index 00000000..d08d04ac --- /dev/null +++ b/strix/interface/tui/sidecar.py @@ -0,0 +1,191 @@ +"""Launch, authenticate, and supervise the Go TUI sidecar process.""" + +from __future__ import annotations + +import asyncio +import contextlib +import hmac +import os +import secrets +import socket +import subprocess +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +from typing import Any + + +_WINDOWS_AUTH_TIMEOUT = 10.0 +_PROCESS_EXIT_TIMEOUT = 5.0 +_SENSITIVE_ENV_SUFFIXES = ("_API_KEY", "_ACCESS_KEY") +_SENSITIVE_ENV_PARTS = frozenset( + {"CREDENTIAL", "CREDENTIALS", "PASSWORD", "SECRET", "SECRETS", "TOKEN", "TOKENS"} +) +_SENSITIVE_ENV_NAMES = { + "AWS_ACCESS_KEY_ID", + "GOOGLE_APPLICATION_CREDENTIALS", + "LLM_API_KEY", + "STRIX_TUI_ADDR", + "STRIX_TUI_FD", + "STRIX_TUI_TOKEN", +} + + +def tui_executable() -> str: + return "strix-tui.exe" if os.name == "nt" else "strix-tui" + + +def project_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def tui_source_dir() -> Path: + return Path(__file__).resolve().parent + + +def child_environment() -> dict[str, str]: + """Copy only non-secret process state needed by the terminal sidecar.""" + child: dict[str, str] = {} + for key, value in os.environ.items(): + normalized = key.upper() + if normalized in _SENSITIVE_ENV_NAMES: + continue + if normalized.endswith(_SENSITIVE_ENV_SUFFIXES): + continue + if set(normalized.split("_")) & _SENSITIVE_ENV_PARTS: + continue + child[key] = value + return child + + +def _recv_exactly(connection: socket.socket, size: int) -> bytes: + chunks: list[bytes] = [] + remaining = size + while remaining: + chunk = connection.recv(remaining) + if not chunk: + raise ConnectionError("TUI IPC peer closed during authentication") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def _authenticate_connection( + connection: socket.socket, + address: tuple[Any, ...], + expected_token: str, +) -> None: + if address[0] not in {"127.0.0.1", "::1"}: + raise ConnectionError("TUI IPC connection did not originate from loopback") + connection.settimeout(_WINDOWS_AUTH_TIMEOUT) + supplied = _recv_exactly(connection, len(expected_token)).decode("ascii") + if not hmac.compare_digest(supplied, expected_token): + raise PermissionError("TUI IPC authentication failed") + connection.settimeout(None) + + +def _accept_authenticated_connection( + listener: socket.socket, + expected_token: str, +) -> socket.socket: + """Accept and authenticate the one Windows loopback connection.""" + listener.settimeout(_WINDOWS_AUTH_TIMEOUT) + connection, address = listener.accept() + try: + _authenticate_connection(connection, address, expected_token) + except BaseException: + connection.close() + raise + return connection + + +async def wait_process( + process: asyncio.subprocess.Process | subprocess.Popen[bytes], +) -> int: + if isinstance(process, asyncio.subprocess.Process): + return await process.wait() + return await asyncio.to_thread(process.wait) + + +async def terminate_process( + process: asyncio.subprocess.Process | subprocess.Popen[bytes] | None, +) -> None: + if process is None or process.returncode is not None: + return + with contextlib.suppress(ProcessLookupError): + process.terminate() + wait_task = asyncio.create_task(wait_process(process)) + try: + await asyncio.wait_for(asyncio.shield(wait_task), _PROCESS_EXIT_TIMEOUT) + except TimeoutError: + with contextlib.suppress(ProcessLookupError): + process.kill() + await asyncio.wait_for(asyncio.shield(wait_task), _PROCESS_EXIT_TIMEOUT) + + +async def launch_tui_process( + command: list[str], + env: dict[str, str], + cwd: str | None, +) -> tuple[asyncio.subprocess.Process | subprocess.Popen[bytes], socket.socket]: + if os.name == "nt": + return await _launch_windows_tui_process(command, env, cwd) + return await _launch_posix_tui_process(command, env, cwd) + + +async def _launch_posix_tui_process( + command: list[str], + env: dict[str, str], + cwd: str | None, +) -> tuple[asyncio.subprocess.Process, socket.socket]: + backend_socket, child_socket = socket.socketpair() + try: + env["STRIX_TUI_FD"] = str(child_socket.fileno()) + process = await asyncio.create_subprocess_exec( + *command, env=env, cwd=cwd, pass_fds=(child_socket.fileno(),) + ) + except BaseException: + backend_socket.close() + raise + finally: + child_socket.close() + return process, backend_socket + + +async def _launch_windows_tui_process( + command: list[str], + env: dict[str, str], + cwd: str | None, +) -> tuple[subprocess.Popen[bytes], socket.socket]: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + windows_process: subprocess.Popen[bytes] | None = None + connection: socket.socket | None = None + try: + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + token = secrets.token_hex(32) + host, port = listener.getsockname()[:2] + env.update({"STRIX_TUI_ADDR": f"{host}:{port}", "STRIX_TUI_TOKEN": token}) + windows_process = subprocess.Popen(command, env=env, cwd=cwd) # noqa: S603 + connection = await asyncio.to_thread(_accept_authenticated_connection, listener, token) + except BaseException: + await terminate_process(windows_process) + raise + finally: + listener.close() + assert windows_process is not None and connection is not None + return windows_process, connection + + +def check_return_code(return_code: int) -> None: + if return_code != 0: + raise RuntimeError(f"Bubble Tea TUI exited with status {return_code}") + + +def package_version() -> str: + """Report the installed package version for the Go splash/stats + ("dev" when metadata is unavailable).""" + try: + return version("strix-agent") + except PackageNotFoundError: + return "dev" diff --git a/strix/interface/update_check.py b/strix/interface/update_check.py index 71159267..f0d22ae6 100644 --- a/strix/interface/update_check.py +++ b/strix/interface/update_check.py @@ -97,19 +97,19 @@ def _is_newer(latest: str, current: str) -> bool: def _fetch_latest_version() -> str | None: try: if is_binary_install(): - response = requests.get( + with requests.get( f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest", timeout=REQUEST_TIMEOUT_SECONDS, - ) - response.raise_for_status() - tag = response.json().get("tag_name", "") + ) as response: + response.raise_for_status() + tag = response.json().get("tag_name", "") return tag.lstrip("v") or None - response = requests.get( + with requests.get( f"https://pypi.org/pypi/{PYPI_PACKAGE}/json", timeout=REQUEST_TIMEOUT_SECONDS, - ) - response.raise_for_status() - version = response.json().get("info", {}).get("version") + ) as response: + response.raise_for_status() + version = response.json().get("info", {}).get("version") return str(version) if version else None except Exception: # noqa: BLE001 logger.debug("update check failed", exc_info=True) @@ -119,12 +119,13 @@ def _fetch_latest_version() -> str | None: def _fetch_asset_digest(version: str, filename: str) -> str | None: """Return the expected sha256 (hex) for a release asset, if the API provides one.""" try: - response = requests.get( + with requests.get( f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{version}", timeout=REQUEST_TIMEOUT_SECONDS, - ) - response.raise_for_status() - for asset in response.json().get("assets", []): + ) as response: + response.raise_for_status() + assets = response.json().get("assets", []) + for asset in assets: if asset.get("name") == filename: digest = asset.get("digest") or "" if digest.startswith("sha256:"): diff --git a/strix/interface/utils.py b/strix/interface/utils.py index 826a08ba..c70e45cf 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -252,7 +252,7 @@ def _llm_usage(report_state: Any) -> dict[str, Any]: return usage if isinstance(usage, dict) else {} -def _is_subscription(report_state: Any) -> bool: +def is_subscription_run(report_state: Any) -> bool: """Whether this run uses a model subscription (no metered cost). Prefers the run record so it's correct for hydrated/resumed runs; falls back @@ -301,7 +301,7 @@ def _build_llm_usage_stats( *, live: bool = False, ) -> None: - subscription = _is_subscription(report_state) + subscription = is_subscription_run(report_state) usage = _llm_usage(report_state) if not usage or _int_stat(usage, "requests") <= 0: stats_text.append("\n") @@ -365,7 +365,7 @@ def build_live_stats_text(report_state: Any) -> Text: model = load_settings().llm.model or "unknown" stats_text.append("Model ", style="dim") stats_text.append(str(model), style="white") - if _is_subscription(report_state): + if is_subscription_run(report_state): stats_text.append(" Β· ", style="dim white") stats_text.append("ChatGPT subscription", style="#22c55e") stats_text.append("\n") @@ -410,7 +410,7 @@ def build_tui_stats_text(report_state: Any) -> Text: model = load_settings().llm.model or "unknown" stats_text.append(str(model), style="white") - subscription = _is_subscription(report_state) + subscription = is_subscription_run(report_state) if subscription: stats_text.append("\n") stats_text.append("ChatGPT subscription", style="#22c55e") @@ -1092,12 +1092,12 @@ def resolve_diff_scope_context( def _is_http_git_repo(url: str) -> bool: check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack" try: - resp = requests.get(check_url, headers={"User-Agent": "git/strix"}, timeout=10) + with requests.get(check_url, headers={"User-Agent": "git/strix"}, timeout=10) as resp: + if resp.status_code >= 400: + return resp.status_code == 401 + return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "") except (requests.RequestException, ValueError): return False - if resp.status_code >= 400: - return resp.status_code == 401 - return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "") def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911 @@ -1475,43 +1475,13 @@ def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) return str(clone_path.absolute()) except subprocess.CalledProcessError as e: - error_text = Text() - error_text.append("REPOSITORY CLONE FAILED", style="bold red") - error_text.append("\n\n", style="white") - error_text.append(f"Could not clone repository: {repo_url}\n", style="white") - error_text.append( - f"Error: {e.stderr if hasattr(e, 'stderr') and e.stderr else str(e)}", style="dim red" - ) - - panel = Panel( - error_text, - title="[bold white]STRIX", - title_align="left", - border_style="red", - padding=(1, 2), - ) - console.print("\n") - console.print(panel) - console.print() - sys.exit(1) - except FileNotFoundError: - error_text = Text() - error_text.append("GIT NOT FOUND", style="bold red") - error_text.append("\n\n", style="white") - error_text.append("Git is not installed or not available in PATH.\n", style="white") - error_text.append("Please install Git to clone repositories.\n", style="white") - - panel = Panel( - error_text, - title="[bold white]STRIX", - title_align="left", - border_style="red", - padding=(1, 2), - ) - console.print("\n") - console.print(panel) - console.print() - sys.exit(1) + detail = e.stderr if hasattr(e, "stderr") and e.stderr else str(e) + raise ValueError(f"Could not clone repository {repo_url}: {detail}") from e + except FileNotFoundError as e: + raise ValueError( + "Git is not installed or not available in PATH. " + "Please install Git to clone repositories." + ) from e def check_docker_connection() -> Any: diff --git a/strix/interface/viewer/auth.py b/strix/interface/viewer/auth.py index a1f137bc..45710811 100644 --- a/strix/interface/viewer/auth.py +++ b/strix/interface/viewer/auth.py @@ -148,16 +148,16 @@ def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int """ url = f"{_app_url()}{path}" try: - response = requests.post( + with requests.post( url, json=payload, headers={"Accept": "application/json"}, timeout=timeout, - ) + ) as response: + return response.status_code, _parse_body(response.content) except requests.RequestException as exc: logger.warning("relay request to %s failed: %s", path, exc) raise RelayError("unavailable") from exc - return response.status_code, _parse_body(response.content) def _parse_body(raw: bytes) -> dict[str, Any]: diff --git a/strix/interface/viewer/frontend/src/components/live/tool-renderers/ViewImageRenderer.tsx b/strix/interface/viewer/frontend/src/components/live/tool-renderers/ViewImageRenderer.tsx index b94282aa..d38d7b0b 100644 --- a/strix/interface/viewer/frontend/src/components/live/tool-renderers/ViewImageRenderer.tsx +++ b/strix/interface/viewer/frontend/src/components/live/tool-renderers/ViewImageRenderer.tsx @@ -3,16 +3,32 @@ import type { ToolRendererProps } from "@/types/events"; import { shortPath } from "./utils"; -/** Mirrors the OSS TUI `ViewImageRenderer`: surfaces load errors, otherwise a - * compact "view image " line. */ +const IMAGE_DATA_URI_RE = /data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/]+={0,2})/; + +function extractImageDataUri(res: unknown): string | null { + let s: string | null = null; + if (typeof res === "string") { + s = res; + } else if (res && typeof res === "object") { + const o = res as Record; + if (typeof o.image_url === "string") s = o.image_url; + else if (typeof o.url === "string") s = o.url; + } + if (!s) return null; + const m = IMAGE_DATA_URI_RE.exec(s); + if (!m || m[2].length < 100 || m[2].length % 4 !== 0) return null; + return `data:image/${m[1]};base64,${m[2]}`; +} + +/** Renders the view_image result as an inline image, like the strix-app + * renderer; falls back to the load-error text when there is no payload. */ export default function ViewImageRenderer({ args, result }: ToolRendererProps) { const path = ((args.path as string) ?? "").trim(); - const res = result as Record | string | null; + const imgSrc = extractImageDataUri(result); let error: string | null = null; - if (typeof res === "string") { - const trimmed = res.trim(); - // A string result that isn't an image payload or structured data is an error message + if (!imgSrc && typeof result === "string") { + const trimmed = result.trim(); if (trimmed && !trimmed.toLowerCase().startsWith("data:image/") && !trimmed.startsWith("{")) { error = trimmed; } @@ -24,6 +40,13 @@ export default function ViewImageRenderer({ args, result }: ToolRendererProps) { view image {path && {shortPath(path)}} + {imgSrc && ( + {path + )} {error &&
{error}
} ); diff --git a/strix/interface/viewer/static/assets/index-C3kQ5kk8.css b/strix/interface/viewer/static/assets/index-C3kQ5kk8.css deleted file mode 100644 index 1d9e3318..00000000 --- a/strix/interface/viewer/static/assets/index-C3kQ5kk8.css +++ /dev/null @@ -1,10 +0,0 @@ -pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! - Theme: GitHub Dark - Description: Dark theme as seen on github.com - Author: github.com - Maintainer: @Hirse - Updated: 2021-05-15 - - Outdated base version: https://github.com/primer/github-syntax-dark - Current colors taken from GitHub's CSS -*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#79c0ff}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-comment,.hljs-code,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #555;--xy-background-pattern-lines-color-default: #333;--xy-background-pattern-cross-color-default: #333;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1;touch-action:none}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))}/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-amber-400:oklch(82.8% .189 84.429);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-sky-400:oklch(74.6% .16 232.661);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-neutral-200:oklch(92.2% 0 none);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-xl:36rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-geist-sans);--default-mono-font-family:var(--font-geist-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.inset-x-0{inset-inline:0}.top-0{top:0}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-full{bottom:100%}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.-mx-2{margin-inline:calc(var(--spacing) * -2)}.mx-0{margin-inline:0}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.my-4{margin-block:calc(var(--spacing) * 4)}.-mt-0\.5{margin-top:calc(var(--spacing) * -.5)}.mt-0{margin-top:0}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-\[1px\]{margin-top:1px}.-mr-0\.5{margin-right:calc(var(--spacing) * -.5)}.mr-1{margin-right:var(--spacing)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.\!h-1\.5{height:calc(var(--spacing) * 1.5)!important}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-\[30px\]{height:30px}.h-\[60vh\]{height:60vh}.h-\[72px\]{height:72px}.h-\[480px\]{height:480px}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[160px\]{max-height:160px}.max-h-\[400px\]{max-height:400px}.max-h-\[1200px\]{max-height:1200px}.min-h-screen{min-height:100vh}.\!w-1\.5{width:calc(var(--spacing) * 1.5)!important}.w-0\.5{width:calc(var(--spacing) * .5)}.w-1{width:var(--spacing)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-28{width:calc(var(--spacing) * 28)}.w-96{width:calc(var(--spacing) * 96)}.w-\[1px\]{width:1px}.w-\[30px\]{width:30px}.w-\[180px\]{width:180px}.w-\[260px\]{width:260px}.w-\[calc\(100vw-4rem\)\]{width:calc(100vw - 4rem)}.w-full{width:100%}.w-max{width:max-content}.w-px{width:1px}.max-w-6xl{max-width:var(--container-6xl)}.max-w-\[75\%\]{max-width:75%}.max-w-\[80\%\]{max-width:80%}.max-w-\[88rem\]{max-width:88rem}.max-w-\[140px\]{max-width:140px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-\[80px\]{min-width:80px}.min-w-\[112px\]{min-width:112px}.min-w-\[160px\]{min-width:160px}.flex-1{flex:1}.flex-none{flex:none}.flex-shrink-0,.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.origin-left{transform-origin:0}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-90{rotate:-90deg}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize-none{resize:none}.resize-y{resize:vertical}.scrollbar-thin{scrollbar-width:thin}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-\[7rem_1fr\]{grid-template-columns:7rem 1fr}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.place-content-center{place-content:center}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}.gap-px{gap:1px}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-6{row-gap:calc(var(--spacing) * 6)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-clip{overflow-x:clip}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\!border-none{--tw-border-style:none!important;border-style:none!important}.\!border-\[\#222\]{border-color:#222!important}.border-\[\#1a1a1a\]{border-color:#1a1a1a}.border-\[\#2a2a2a\]{border-color:#2a2a2a}.border-\[\#3a3a3a\]{border-color:#3a3a3a}.border-\[\#22c55e\]\/40{border-color:#22c55e66}.border-\[\#222\]{border-color:#222}.border-\[\#333\]{border-color:#333}.border-\[\#444\]{border-color:#444}.border-\[\#191919\]{border-color:#191919}.border-\[rgba\(255\,255\,255\,0\.08\)\]{border-color:#ffffff14}.border-blue-500\/20{border-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/20{border-color:color-mix(in oklab,var(--color-blue-500) 20%,transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/30{border-color:color-mix(in oklab,var(--color-blue-500) 30%,transparent)}}.border-blue-500\/40{border-color:#3080ff66}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/40{border-color:color-mix(in oklab,var(--color-blue-500) 40%,transparent)}}.border-emerald-500\/20{border-color:#00bb7f33}@supports (color:color-mix(in lab,red,red)){.border-emerald-500\/20{border-color:color-mix(in oklab,var(--color-emerald-500) 20%,transparent)}}.border-emerald-500\/25{border-color:#00bb7f40}@supports (color:color-mix(in lab,red,red)){.border-emerald-500\/25{border-color:color-mix(in oklab,var(--color-emerald-500) 25%,transparent)}}.border-emerald-500\/30{border-color:#00bb7f4d}@supports (color:color-mix(in lab,red,red)){.border-emerald-500\/30{border-color:color-mix(in oklab,var(--color-emerald-500) 30%,transparent)}}.border-gray-500\/20{border-color:#6a728233}@supports (color:color-mix(in lab,red,red)){.border-gray-500\/20{border-color:color-mix(in oklab,var(--color-gray-500) 20%,transparent)}}.border-orange-500\/20{border-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/20{border-color:color-mix(in oklab,var(--color-orange-500) 20%,transparent)}}.border-orange-500\/30{border-color:#fe6e004d}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/30{border-color:color-mix(in oklab,var(--color-orange-500) 30%,transparent)}}.border-purple-500\/20{border-color:#ac4bff33}@supports (color:color-mix(in lab,red,red)){.border-purple-500\/20{border-color:color-mix(in oklab,var(--color-purple-500) 20%,transparent)}}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.border-red-500\/20{border-color:color-mix(in oklab,var(--color-red-500) 20%,transparent)}}.border-red-500\/25{border-color:#fb2c3640}@supports (color:color-mix(in lab,red,red)){.border-red-500\/25{border-color:color-mix(in oklab,var(--color-red-500) 25%,transparent)}}.border-red-500\/30{border-color:#fb2c364d}@supports (color:color-mix(in lab,red,red)){.border-red-500\/30{border-color:color-mix(in oklab,var(--color-red-500) 30%,transparent)}}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.border-white\/10{border-color:color-mix(in oklab,var(--color-white) 10%,transparent)}}.border-white\/30{border-color:#ffffff4d}@supports (color:color-mix(in lab,red,red)){.border-white\/30{border-color:color-mix(in oklab,var(--color-white) 30%,transparent)}}.border-white\/\[0\.06\]{border-color:#ffffff0f}@supports (color:color-mix(in lab,red,red)){.border-white\/\[0\.06\]{border-color:color-mix(in oklab,var(--color-white) 6%,transparent)}}.border-white\/\[0\.08\]{border-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.border-white\/\[0\.08\]{border-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.border-white\/\[0\.18\]{border-color:#ffffff2e}@supports (color:color-mix(in lab,red,red)){.border-white\/\[0\.18\]{border-color:color-mix(in oklab,var(--color-white) 18%,transparent)}}.border-yellow-500\/20{border-color:#edb20033}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\/20{border-color:color-mix(in oklab,var(--color-yellow-500) 20%,transparent)}}.border-yellow-500\/25{border-color:#edb20040}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\/25{border-color:color-mix(in oklab,var(--color-yellow-500) 25%,transparent)}}.border-yellow-500\/30{border-color:#edb2004d}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\/30{border-color:color-mix(in oklab,var(--color-yellow-500) 30%,transparent)}}.border-t-white{border-top-color:var(--color-white)}.\!bg-\[\#0a0a0a\]{background-color:#0a0a0a!important}.\!bg-\[\#444\]{background-color:#444!important}.\!bg-transparent{background-color:#0000!important}.bg-\[\#0a0a0a\]{background-color:#0a0a0a}.bg-\[\#1a1a1a\]{background-color:#1a1a1a}.bg-\[\#1e1e1e\]{background-color:#1e1e1e}.bg-\[\#2a2a2a\]{background-color:#2a2a2a}.bg-\[\#22c55e\]\/10{background-color:#22c55e1a}.bg-\[\#111\]{background-color:#111}.bg-\[\#222\]{background-color:#222}.bg-\[\#555\]{background-color:#555}.bg-\[\#888\]{background-color:#888}.bg-\[\#050505\]{background-color:#050505}.bg-\[\#252525\]{background-color:#252525}.bg-\[rgba\(255\,255\,255\,0\.02\)\]{background-color:#ffffff05}.bg-\[rgba\(255\,255\,255\,0\.3\)\]{background-color:#ffffff4d}.bg-\[rgba\(255\,255\,255\,0\.04\)\]{background-color:#ffffff0a}.bg-\[rgba\(255\,255\,255\,0\.05\)\]{background-color:#ffffff0d}.bg-\[rgba\(255\,255\,255\,0\.08\)\]{background-color:#ffffff14}.bg-\[rgba\(255\,255\,255\,0\.12\)\]{background-color:#ffffff1f}.bg-black{background-color:var(--color-black)}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab,red,red)){.bg-black\/80{background-color:color-mix(in oklab,var(--color-black) 80%,transparent)}}.bg-blue-400{background-color:var(--color-blue-400)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/10{background-color:color-mix(in oklab,var(--color-blue-500) 10%,transparent)}}.bg-blue-500\/20{background-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/20{background-color:color-mix(in oklab,var(--color-blue-500) 20%,transparent)}}.bg-blue-500\/\[0\.12\]{background-color:#3080ff1f}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/\[0\.12\]{background-color:color-mix(in oklab,var(--color-blue-500) 12%,transparent)}}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/5{background-color:#00bb7f0d}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/5{background-color:color-mix(in oklab,var(--color-emerald-500) 5%,transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/10{background-color:color-mix(in oklab,var(--color-emerald-500) 10%,transparent)}}.bg-emerald-500\/\[0\.06\]{background-color:#00bb7f0f}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/\[0\.06\]{background-color:color-mix(in oklab,var(--color-emerald-500) 6%,transparent)}}.bg-gray-500{background-color:var(--color-gray-500)}.bg-gray-500\/10{background-color:#6a72821a}@supports (color:color-mix(in lab,red,red)){.bg-gray-500\/10{background-color:color-mix(in oklab,var(--color-gray-500) 10%,transparent)}}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-500\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/10{background-color:color-mix(in oklab,var(--color-orange-500) 10%,transparent)}}.bg-orange-500\/20{background-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/20{background-color:color-mix(in oklab,var(--color-orange-500) 20%,transparent)}}.bg-purple-500{background-color:var(--color-purple-500)}.bg-purple-500\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\/10{background-color:color-mix(in oklab,var(--color-purple-500) 10%,transparent)}}.bg-purple-500\/\[0\.08\]{background-color:#ac4bff14}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\/\[0\.08\]{background-color:color-mix(in oklab,var(--color-purple-500) 8%,transparent)}}.bg-red-400{background-color:var(--color-red-400)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/5{background-color:#fb2c360d}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/5{background-color:color-mix(in oklab,var(--color-red-500) 5%,transparent)}}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500) 10%,transparent)}}.bg-red-500\/15{background-color:#fb2c3626}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/15{background-color:color-mix(in oklab,var(--color-red-500) 15%,transparent)}}.bg-red-500\/20{background-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/20{background-color:color-mix(in oklab,var(--color-red-500) 20%,transparent)}}.bg-red-500\/\[0\.12\]{background-color:#fb2c361f}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/\[0\.12\]{background-color:color-mix(in oklab,var(--color-red-500) 12%,transparent)}}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/8{background-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.bg-white\/8{background-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab,red,red)){.bg-white\/60{background-color:color-mix(in oklab,var(--color-white) 60%,transparent)}}.bg-white\/\[0\.03\]{background-color:#ffffff08}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.03\]{background-color:color-mix(in oklab,var(--color-white) 3%,transparent)}}.bg-white\/\[0\.08\]{background-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.08\]{background-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.bg-white\/\[0\.015\]{background-color:#ffffff04}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.015\]{background-color:color-mix(in oklab,var(--color-white) 1.5%,transparent)}}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-yellow-500\/10{background-color:#edb2001a}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/10{background-color:color-mix(in oklab,var(--color-yellow-500) 10%,transparent)}}.bg-yellow-500\/15{background-color:#edb20026}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/15{background-color:color-mix(in oklab,var(--color-yellow-500) 15%,transparent)}}.bg-yellow-500\/20{background-color:#edb20033}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/20{background-color:color-mix(in oklab,var(--color-yellow-500) 20%,transparent)}}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-emerald-400{--tw-gradient-from:var(--color-emerald-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-cyan-500{--tw-gradient-to:var(--color-cyan-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.object-cover{object-fit:cover}.p-0{padding:0}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-10{padding:calc(var(--spacing) * 10)}.px-0{padding-inline:0}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-8{padding-block:calc(var(--spacing) * 8)}.pt-1{padding-top:var(--spacing)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-\[5px\]{padding-top:5px}.pr-1{padding-right:var(--spacing)}.pr-1\.5{padding-right:calc(var(--spacing) * 1.5)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pb-2\.5{padding-bottom:calc(var(--spacing) * 2.5)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.pl-1\.5{padding-left:calc(var(--spacing) * 1.5)}.pl-2\.5{padding-left:calc(var(--spacing) * 2.5)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:var(--font-geist-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[15px\]{font-size:15px}.text-\[16px\]{font-size:16px}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-\[22px\]{--tw-leading:22px;line-height:22px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.4em\]{--tw-tracking:.4em;letter-spacing:.4em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#22c55e\]{color:#22c55e}.text-\[\#333\]{color:#333}.text-\[\#444\]{color:#444}.text-\[\#555\]{color:#555}.text-\[\#666\]{color:#666}.text-\[\#777\]{color:#777}.text-\[\#888\]{color:#888}.text-\[\#999\]{color:#999}.text-\[\#aaa\]{color:#aaa}.text-\[\#bbb\]{color:#bbb}.text-\[\#ddd\]{color:#ddd}.text-\[\#e5e5e5\]{color:#e5e5e5}.text-\[\#ededed\]{color:#ededed}.text-amber-400{color:var(--color-amber-400)}.text-amber-400\/80{color:#fcbb00cc}@supports (color:color-mix(in lab,red,red)){.text-amber-400\/80{color:color-mix(in oklab,var(--color-amber-400) 80%,transparent)}}.text-black{color:var(--color-black)}.text-blue-400{color:var(--color-blue-400)}.text-blue-400\/80{color:#54a2ffcc}@supports (color:color-mix(in lab,red,red)){.text-blue-400\/80{color:color-mix(in oklab,var(--color-blue-400) 80%,transparent)}}.text-blue-500{color:var(--color-blue-500)}.text-cyan-400{color:var(--color-cyan-400)}.text-cyan-400\/80{color:#00d2efcc}@supports (color:color-mix(in lab,red,red)){.text-cyan-400\/80{color:color-mix(in oklab,var(--color-cyan-400) 80%,transparent)}}.text-emerald-200{color:var(--color-emerald-200)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-400\/30{color:#00d2944d}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/30{color:color-mix(in oklab,var(--color-emerald-400) 30%,transparent)}}.text-emerald-400\/60{color:#00d29499}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/60{color:color-mix(in oklab,var(--color-emerald-400) 60%,transparent)}}.text-emerald-400\/70{color:#00d294b3}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/70{color:color-mix(in oklab,var(--color-emerald-400) 70%,transparent)}}.text-emerald-400\/80{color:#00d294cc}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/80{color:color-mix(in oklab,var(--color-emerald-400) 80%,transparent)}}.text-gray-400{color:var(--color-gray-400)}.text-orange-400{color:var(--color-orange-400)}.text-orange-400\/60{color:#ff8b1a99}@supports (color:color-mix(in lab,red,red)){.text-orange-400\/60{color:color-mix(in oklab,var(--color-orange-400) 60%,transparent)}}.text-orange-400\/80{color:#ff8b1acc}@supports (color:color-mix(in lab,red,red)){.text-orange-400\/80{color:color-mix(in oklab,var(--color-orange-400) 80%,transparent)}}.text-orange-500{color:var(--color-orange-500)}.text-purple-400{color:var(--color-purple-400)}.text-purple-400\/60{color:#c07eff99}@supports (color:color-mix(in lab,red,red)){.text-purple-400\/60{color:color-mix(in oklab,var(--color-purple-400) 60%,transparent)}}.text-purple-400\/70{color:#c07effb3}@supports (color:color-mix(in lab,red,red)){.text-purple-400\/70{color:color-mix(in oklab,var(--color-purple-400) 70%,transparent)}}.text-purple-400\/80{color:#c07effcc}@supports (color:color-mix(in lab,red,red)){.text-purple-400\/80{color:color-mix(in oklab,var(--color-purple-400) 80%,transparent)}}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-400\/30{color:#ff65684d}@supports (color:color-mix(in lab,red,red)){.text-red-400\/30{color:color-mix(in oklab,var(--color-red-400) 30%,transparent)}}.text-red-400\/50{color:#ff656880}@supports (color:color-mix(in lab,red,red)){.text-red-400\/50{color:color-mix(in oklab,var(--color-red-400) 50%,transparent)}}.text-red-400\/60{color:#ff656899}@supports (color:color-mix(in lab,red,red)){.text-red-400\/60{color:color-mix(in oklab,var(--color-red-400) 60%,transparent)}}.text-red-400\/70{color:#ff6568b3}@supports (color:color-mix(in lab,red,red)){.text-red-400\/70{color:color-mix(in oklab,var(--color-red-400) 70%,transparent)}}.text-red-400\/80{color:#ff6568cc}@supports (color:color-mix(in lab,red,red)){.text-red-400\/80{color:color-mix(in oklab,var(--color-red-400) 80%,transparent)}}.text-red-500{color:var(--color-red-500)}.text-sky-400{color:var(--color-sky-400)}.text-sky-400\/80{color:#00bcfecc}@supports (color:color-mix(in lab,red,red)){.text-sky-400\/80{color:color-mix(in oklab,var(--color-sky-400) 80%,transparent)}}.text-white{color:var(--color-white)}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab,red,red)){.text-white\/80{color:color-mix(in oklab,var(--color-white) 80%,transparent)}}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-400\/80{color:#fac800cc}@supports (color:color-mix(in lab,red,red)){.text-yellow-400\/80{color:color-mix(in oklab,var(--color-yellow-400) 80%,transparent)}}.text-yellow-500{color:var(--color-yellow-500)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.opacity-0{opacity:0}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.\!shadow-none{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[font-variant-ligatures\:none\]{font-variant-ligatures:none}@media(hover:hover){.group-hover\:bg-\[rgba\(255\,255\,255\,0\.2\)\]:is(:where(.group):hover *){background-color:#fff3}.group-hover\:text-\[\#aaa\]:is(:where(.group):hover *){color:#aaa}.group-hover\:text-white:is(:where(.group):hover *){color:var(--color-white)}.group-hover\:opacity-90:is(:where(.group):hover *){opacity:.9}.group-hover\/code\:opacity-100:is(:where(.group\/code):hover *){opacity:1}}.placeholder\:text-\[\#444\]::placeholder{color:#444}@media(hover:hover){.hover\:border-\[\#333\]:hover{border-color:#333}.hover\:border-\[\#444\]:hover{border-color:#444}.hover\:border-\[\#555\]:hover{border-color:#555}.hover\:border-emerald-500\/40:hover{border-color:#00bb7f66}@supports (color:color-mix(in lab,red,red)){.hover\:border-emerald-500\/40:hover{border-color:color-mix(in oklab,var(--color-emerald-500) 40%,transparent)}}.hover\:border-white\/\[0\.12\]:hover{border-color:#ffffff1f}@supports (color:color-mix(in lab,red,red)){.hover\:border-white\/\[0\.12\]:hover{border-color:color-mix(in oklab,var(--color-white) 12%,transparent)}}.hover\:border-white\/\[0\.16\]:hover{border-color:#ffffff29}@supports (color:color-mix(in lab,red,red)){.hover\:border-white\/\[0\.16\]:hover{border-color:color-mix(in oklab,var(--color-white) 16%,transparent)}}.hover\:bg-\[\#1a1a1a\]:hover{background-color:#1a1a1a}.hover\:bg-\[\#2a2a2a\]:hover{background-color:#2a2a2a}.hover\:bg-\[rgba\(255\,255\,255\,0\.06\)\]:hover{background-color:#ffffff0f}.hover\:bg-\[rgba\(255\,255\,255\,0\.08\)\]:hover{background-color:#ffffff14}.hover\:bg-\[rgba\(255\,255\,255\,0\.09\)\]:hover{background-color:#ffffff17}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:bg-white\/\[0\.06\]:hover{background-color:#ffffff0f}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.06\]:hover{background-color:color-mix(in oklab,var(--color-white) 6%,transparent)}}.hover\:text-\[\#888\]:hover{color:#888}.hover\:text-\[\#aaa\]:hover{color:#aaa}.hover\:text-\[\#ccc\]:hover{color:#ccc}.hover\:text-\[\#ededed\]:hover{color:#ededed}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}}.focus\:border-\[\#444\]:focus{border-color:#444}.focus\:border-white\/50:focus{border-color:#ffffff80}@supports (color:color-mix(in lab,red,red)){.focus\:border-white\/50:focus{border-color:color-mix(in oklab,var(--color-white) 50%,transparent)}}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-white\/10:focus{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.focus\:ring-white\/10:focus{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-white\/20:focus-visible{--tw-ring-color:#fff3}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-white\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:opacity-60:disabled{opacity:.6}@media(min-width:40rem){.sm\:rounded-lg{border-radius:var(--radius-lg)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}}@media(min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-6{top:calc(var(--spacing) * 6)}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:max-h-\[calc\(100vh-3rem\)\]{max-height:calc(100vh - 3rem)}.lg\:grid-cols-\[1fr_340px\]{grid-template-columns:1fr 340px}.lg\:overflow-y-auto{overflow-y:auto}.lg\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.lg\:border-\[\#2a2a2a\]{border-color:#2a2a2a}.lg\:pl-6{padding-left:calc(var(--spacing) * 6)}}.\[\&_svg\]\:h-3\.5 svg{height:calc(var(--spacing) * 3.5)}.\[\&_svg\]\:w-3\.5 svg{width:calc(var(--spacing) * 3.5)}}:root{--font-geist-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;--font-geist-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}html,body{color:#fff;font-family:var(--font-geist-sans);background:#000}.scrollbar-thin{scrollbar-width:thin;scrollbar-color:#ffffff26 transparent}.scrollbar-thin::-webkit-scrollbar{width:6px;height:6px}.scrollbar-thin::-webkit-scrollbar-thumb{background:#ffffff26;border-radius:3px}.scrollbar-thin::-webkit-scrollbar-track{background:0 0}@keyframes page-in{0%{opacity:0;filter:blur(8px);transform:translateY(8px)}to{opacity:1;filter:blur();transform:translateY(0)}}.animate-page-in{animation:.15s ease-out page-in}@keyframes fade-in{0%{opacity:0}to{opacity:1}}.animate-fade-in{animation:.35s ease-out fade-in}@keyframes cardIn{0%{opacity:0;filter:blur(4px);transform:translateY(8px)scale(.97)}to{opacity:1;filter:blur();transform:translateY(0)scale(1)}}.animate-card-in{opacity:0;animation:.3s cubic-bezier(.16,1,.3,1) forwards cardIn}.animate-card-in:first-child{animation-delay:0s}.animate-card-in:nth-child(2){animation-delay:50ms}.animate-card-in:nth-child(3){animation-delay:.1s}.animate-card-in:nth-child(4){animation-delay:.15s}@keyframes shimmer{0%{transform:translate(-100%)}to{transform:translate(400%)}}.animate-shimmer{animation:2s infinite shimmer}@keyframes dialog-overlay-in{0%{opacity:0}to{opacity:1}}@keyframes dialog-overlay-out{0%{opacity:1}to{opacity:0}}@keyframes dialog-panel-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes dialog-panel-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.95)}}.dialog-overlay[data-state=open]{animation:.2s dialog-overlay-in}.dialog-overlay[data-state=closed]{animation:.2s forwards dialog-overlay-out}.dialog-panel[data-state=open]{animation:.2s dialog-panel-in}.dialog-panel[data-state=closed]{animation:.2s forwards dialog-panel-out}.agent-modal[data-state=open]{animation:.14s dialog-overlay-in}.agent-modal[data-state=closed]{animation:.14s forwards dialog-overlay-out}@keyframes tab-in{0%{opacity:0;filter:blur(4px);transform:translateY(6px)}to{opacity:1;filter:blur();transform:translateY(0)}}.animate-tab-in{animation:.2s ease-out tab-in}.prose-markdown{color:#999;word-wrap:break-word;overflow-wrap:break-word;font-size:14px;line-height:1.7}.prose-markdown p{margin-bottom:.75em}.prose-markdown p:last-child{margin-bottom:0}.prose-markdown strong{color:#ccc;font-weight:600}.prose-markdown em{font-style:italic}.prose-markdown code{color:#ccc;font-variant-ligatures:none;background:#0a0a0a;border:1px solid #111;border-radius:4px;padding:.15em .4em;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:.9em}.prose-markdown pre{font-variant-ligatures:none;background:0 0;border:none;border-radius:0;margin:0;padding:0}.prose-markdown pre code{color:inherit;background:0 0;border:none;padding:0;font-size:13px}.prose-markdown ul,.prose-markdown ol{margin-bottom:.75em;padding-left:1.5em}.prose-markdown ul{list-style-type:disc}.prose-markdown ol{list-style-type:decimal}.prose-markdown li{margin-bottom:.25em}.prose-markdown li>ul,.prose-markdown li>ol{margin-top:.25em;margin-bottom:.25em;padding-left:1.5em}.prose-markdown ol+ul{margin-top:-.5em;padding-left:3em}.prose-markdown h1,.prose-markdown h2,.prose-markdown h3,.prose-markdown h4,.prose-markdown h5,.prose-markdown h6{color:#ddd;margin-top:1em;margin-bottom:.5em;font-weight:600}.prose-markdown a{color:inherit;pointer-events:none;text-decoration:none}.prose-markdown blockquote{color:#777;border-left:3px solid #333;margin:.75em 0;padding-left:1em}.prose-markdown hr{border:none;border-top:1px solid #222;margin:1em 0}.prose-markdown>table{border-collapse:collapse;width:100%;margin:.75em 0}.prose-markdown>table th,.prose-markdown>table td{text-align:left;border:1px solid #333;padding:.4em .75em;font-size:13px}.prose-markdown>table th{color:#ccc;background:#1a1a1a;font-weight:600}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} diff --git a/strix/interface/viewer/static/assets/index-CGvQq6oe.js b/strix/interface/viewer/static/assets/index-DBJ-RJqo.js similarity index 98% rename from strix/interface/viewer/static/assets/index-CGvQq6oe.js rename to strix/interface/viewer/static/assets/index-DBJ-RJqo.js index a6e52674..ecdc0fcf 100644 --- a/strix/interface/viewer/static/assets/index-CGvQq6oe.js +++ b/strix/interface/viewer/static/assets/index-DBJ-RJqo.js @@ -473,15 +473,15 @@ https://github.com/highlightjs/highlight.js/issues/2277`),Ht=ye,ft=Le),Qe===void `)}function e7({toolName:e,args:t,result:r}){const a=e==="write_stdin",s=a?t.chars??t.input??"":t.command??t.cmd??"",o=r;let c=null,d=null,h=null;if(o&&typeof o=="object"){c=typeof o.content=="string"?o.content:null,d=typeof o.error=="string"?o.error:null,h=typeof o.exit_code=="number"?o.exit_code:null;const m=typeof o.status=="string"?o.status:"";(m==="running"||m==="command still running")&&(c=null)}else typeof o=="string"&&(c=o);const f=c?JB(WB(c,s)):null;return g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:a?"Terminal input":"Terminal"}),s&&g.jsx(dg,{code:s,language:"bash",collapsible:!0}),d&&g.jsx(wi,{className:"text-red-400/70",children:d}),f&&g.jsx(wi,{className:"text-[#666]",children:f}),h!=null&&h!==0&&g.jsxs("div",{className:"font-mono text-[13px] text-red-400/70 mt-0.5",children:["exit code ",h]})]})}const n_={back:"going back in browser history",forward:"going forward in browser history",scroll_down:"scrolling down",scroll_up:"scrolling up",refresh:"refreshing",close_tab:"closing tab",switch_tab:"switching tab",list_tabs:"listing tabs",view_source:"viewing page source",get_console_logs:"getting console logs",screenshot:"taking screenshot",wait:"waiting...",close:"closing"},r_={click:"clicking",double_click:"double clicking",hover:"hovering"};function Rm({prefix:e,url:t,suffix:r}){return g.jsxs("span",{className:"text-[#888] text-[13px]",children:[e,t&&g.jsx("a",{href:t,target:"_blank",rel:"noopener noreferrer",className:"text-cyan-400/80 hover:underline",children:t}),r]})}function t7(e){const t=e.action??"",r=e.url??void 0;if(t in n_)return n_[t];if(t==="launch")return r?g.jsx(Rm,{prefix:"launching ",url:r}):"launching";if(t==="goto"||t==="navigate")return g.jsx(Rm,{prefix:"navigating to ",url:r});if(t==="new_tab")return g.jsx(Rm,{prefix:"opening tab ",url:r});if(t in r_)return r_[t];if(t==="type")return`typing "${(e.text??"").slice(0,40)}"`;if(t==="press_key"||t==="key_press")return`pressing key ${e.key??""}`;if(t==="save_pdf"||t==="save_as_pdf"){const a=e.file_path??"";return`saving PDF${a?` to ${a}`:""}`}return t==="execute_js"?"executing javascript":t||"browser action"}function n7({args:e}){const r=(e.action??"")==="execute_js"?e.js_code??e.code??"":"",a=t7(e);return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 min-w-0",children:[g.jsx("span",{className:"text-blue-400/80 font-semibold text-sm shrink-0",children:"Browser"}),g.jsx("span",{className:"min-w-0 truncate text-[#888] text-[13px]",children:a})]}),r&&g.jsx(dg,{code:r,language:"javascript",collapsible:!0})]})}function fg(e){return e.length>60?"..."+e.slice(-57):e}const fu=30;function r7({toolName:e,args:t}){const r=t.path??t.file_path??"",a=t.command??"",s=t.old_str??"",o=t.new_str??"",c=t.regex??"";let d;e==="list_files"?d="list":e==="search_files"?d="search":a==="view"?d="view":a==="create"?d="create":a==="str_replace"?d="edit":a==="undo_edit"?d="undo":a==="insert"?d="insert":d="file";const h=r?fg(r):"",f=c?` /${c}/`:"",m=s?s.split(` `):[],p=o?o.split(` `):[],y=m.length+p.length,x=y>fu,_=x?Math.round(fu*(m.length/y)):m.length,N=x?fu-_:p.length;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2",children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm shrink-0",children:d}),h&&g.jsx("span",{className:"text-[#888] font-mono text-[13px] break-all",children:h})]}),f&&g.jsx("div",{className:"text-purple-400/60 font-mono text-[13px] break-all mt-0.5",children:f}),(s||o)&&g.jsxs("div",{className:"font-mono text-[13px] leading-relaxed mt-1.5",children:[m.slice(0,_).map((S,w)=>g.jsxs("div",{className:"text-red-400/60",children:[g.jsx("span",{className:"select-none text-red-400/30 mr-1",children:"-"}),S]},`o${w}`)),p.slice(0,N).map((S,w)=>g.jsxs("div",{className:"text-emerald-400/60",children:[g.jsx("span",{className:"select-none text-emerald-400/30 mr-1",children:"+"}),S]},`n${w}`)),x&&g.jsxs("div",{className:"text-[#444] mt-0.5",children:["... ",y-fu," more lines"]})]})]})}const hu=30,i7="*** Begin Patch",a7="*** End Patch",i_="*** Add File: ",a_="*** Update File: ",s_="*** Delete File: ",s7={add:"create",update:"edit",delete:"delete"};function l7(e){const t=e.patch;return typeof t=="string"?t:t&&typeof t=="object"&&typeof t.patch=="string"?t.patch:typeof e.input=="string"?e.input:""}function o7(e){const t=[];let r=null;const a=()=>{r&&t.push(r),r=null};for(const s of e.split(` -`))if(!(s===i7||s===a7))if(s.startsWith(i_))a(),r={kind:"add",path:s.slice(i_.length).trim(),oldLines:[],newLines:[]};else if(s.startsWith(a_))a(),r={kind:"update",path:s.slice(a_.length).trim(),oldLines:[],newLines:[]};else if(s.startsWith(s_))a(),r={kind:"delete",path:s.slice(s_.length).trim(),oldLines:[],newLines:[]};else if((r==null?void 0:r.kind)==="update"){if(s.startsWith("@@"))continue;s.startsWith("-")&&!s.startsWith("---")?r.oldLines.push(s.slice(1)):s.startsWith("+")&&!s.startsWith("+++")&&r.newLines.push(s.slice(1))}else(r==null?void 0:r.kind)==="add"&&(s.startsWith("+")?r.newLines.push(s.slice(1)):s.trim()&&r.newLines.push(s));return a(),t}function c7({op:e}){const t=s7[e.kind]??"file",r=e.oldLines.length+e.newLines.length,a=r>hu,s=a&&r>0?Math.round(hu*(e.oldLines.length/r)):e.oldLines.length,o=a?hu-s:e.newLines.length;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2",children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm shrink-0",children:t}),e.path&&g.jsx("span",{className:"text-[#888] font-mono text-[13px] break-all",children:fg(e.path)})]}),(e.oldLines.length>0||e.newLines.length>0)&&g.jsxs("div",{className:"font-mono text-[13px] leading-relaxed mt-1.5",children:[e.oldLines.slice(0,s).map((c,d)=>g.jsxs("div",{className:"text-red-400/60",children:[g.jsx("span",{className:"select-none text-red-400/30 mr-1",children:"-"}),c]},`o${d}`)),e.newLines.slice(0,o).map((c,d)=>g.jsxs("div",{className:"text-emerald-400/60",children:[g.jsx("span",{className:"select-none text-emerald-400/30 mr-1",children:"+"}),c]},`n${d}`)),a&&g.jsxs("div",{className:"text-[#444] mt-0.5",children:["... ",r-hu," more lines"]})]})]})}function u7({args:e,result:t,status:r}){const a=o7(l7(e));return a.length===0?g.jsxs("div",{children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm",children:"patch"}),r==="failed"&&typeof t=="string"&&t.trim()&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1",children:t.trim()})]}):g.jsxs("div",{className:"space-y-2",children:[a.map((s,o)=>g.jsx(c7,{op:s},o)),r==="failed"&&typeof t=="string"&&t.trim()&&g.jsx("div",{className:"text-red-400/70 text-[13px]",children:t.trim()})]})}function d7({args:e,result:t}){const r=(e.path??"").trim(),a=t;let s=null;if(typeof a=="string"){const o=a.trim();o&&!o.toLowerCase().startsWith("data:image/")&&!o.startsWith("{")&&(s=o)}return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2",children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm shrink-0",children:"view image"}),r&&g.jsx("span",{className:"text-[#888] font-mono text-[13px] break-all",children:fg(r)})]}),s&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1",children:s})]})}const f7={critical:"text-red-400",high:"text-orange-400",medium:"text-yellow-400",low:"text-blue-400",info:"text-cyan-400"};function h7({args:e,result:t}){const r=e.title??"",a=e.description??"",s=e.impact??"",o=e.target??"",c=e.endpoint??"",d=e.method??"",h=e.technical_analysis??"",f=e.poc_description??"",{language:m,code:p}=oE(e.poc_script_code??""),y=e.remediation_steps??"",x=e.cve??"",_=e.cwe??"",N=t,S=(N&&typeof N=="object"?N.severity:null)??e.severity??"medium",w=String(S).toLowerCase(),k=(N&&typeof N=="object"?N.cvss_score:null)??e.cvss??null,E=f7[w]??"text-yellow-400";return g.jsxs("div",{className:"space-y-3",children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[g.jsx("span",{className:`font-semibold text-sm ${E}`,children:w.toUpperCase()}),k!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["CVSS ",k]}),x&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:x}),_&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:_})]}),r&&g.jsx("div",{className:"text-[15px] text-white/80 font-semibold",children:r}),(o||c)&&g.jsxs("div",{className:"text-[13px] text-[#888] font-mono",children:[o,c?` ${d} ${c}`:""]}),a&&g.jsx(An,{text:a,maxLines:20}),s&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Impact"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:s,maxLines:15})})]}),h&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Technical Analysis"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:h,maxLines:20})})]}),(f||p)&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Proof of Concept"}),f&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:f})}),p&&g.jsx(lE,{className:m?`language-${m}`:void 0,children:p})]}),y&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Remediation"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:y,maxLines:15})})]})]})}const m7={critical:"text-red-400",high:"text-orange-400",medium:"text-yellow-400",low:"text-blue-400",info:"text-cyan-400",none:"text-[#888]"};function p7(e){if(!e.agent_name&&!e.by_you)return null;const t=e.by_you?"you":e.agent_name;return g.jsxs("span",{className:"text-[#666] text-xs ml-1.5",children:["(",t,")"]})}function jm(e){const t=String(e??"").toLowerCase(),r=m7[t]??"text-yellow-400";return g.jsx("span",{className:`font-semibold text-[13px] ${r}`,children:t.toUpperCase()||"β€”"})}function l_({toolName:e,result:t}){const r=t,a=r!=null&&typeof r=="object"&&r.success===!0;if(e==="get_report"){const f=a?r.report:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:"text-red-400/80 font-semibold text-sm",children:"report"}),f?g.jsxs("div",{className:"mt-1.5 space-y-2",children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[jm(f.severity),f.cvss!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["CVSS ",f.cvss]}),f.id&&g.jsx("span",{className:"text-[#555] font-mono text-[13px]",children:f.id}),f.cve&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:f.cve}),f.cwe&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:f.cwe}),(f.agent_name||f.by_you)&&g.jsx("span",{className:"text-[#666] text-[13px]",children:f.by_you?"you":f.agent_name})]}),f.title&&g.jsx("div",{className:"text-[15px] text-white/80 font-semibold",children:f.title}),(f.target||f.endpoint)&&g.jsxs("div",{className:"text-[13px] text-[#888] font-mono",children:[f.target,f.endpoint?` ${f.method??""} ${f.endpoint}`:""]}),f.description&&g.jsx(An,{text:f.description,maxLines:20})]}):g.jsx("div",{className:"mt-1 text-[#555] text-xs",children:r&&typeof r=="object"&&r.error||"Report not found"})]})}const s=a?r.reports:null,o=Array.isArray(s)?s:[],c=a&&typeof r.total_count=="number"?r.total_count:o.length,d=a&&r.severity_counts&&typeof r.severity_counts=="object"?r.severity_counts:{},h=Object.entries(d);return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[g.jsx("span",{className:"text-red-400/80 font-semibold text-sm",children:"reports"}),g.jsxs("span",{className:"text-[#555] text-[13px]",children:["(",c,")"]}),h.map(([f,m])=>g.jsxs("span",{className:"text-[13px]",children:[jm(f),g.jsx("span",{className:"text-[#888] ml-0.5",children:m})]},f))]}),o.length>0?g.jsx("div",{className:"mt-1.5 space-y-1",children:o.map((f,m)=>g.jsxs("div",{className:"text-[13px]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"-"}),jm(f.severity),f.id&&g.jsx("span",{className:"text-[#555] font-mono ml-1.5",children:f.id}),g.jsx("span",{className:"text-[#999] ml-1.5",children:f.title??"(untitled)"}),p7(f),(f.target||f.endpoint)&&g.jsxs("div",{className:"ml-3 text-[#666] font-mono text-xs",children:[f.target,f.endpoint?` ${f.method??""} ${f.endpoint}`:""]}),f.description_preview&&g.jsx("div",{className:"ml-3",children:g.jsx(ua,{text:f.description_preview})})]},f.id??m))}):g.jsx("div",{className:"mt-1 text-[#555] text-xs",children:"No reports filed yet"})]})}const eS=200,tS={GET:"text-emerald-400/80",POST:"text-blue-400/80",PUT:"text-yellow-400/80",PATCH:"text-orange-400/80",DELETE:"text-red-400/80"};function hg(e){return e<300?"text-emerald-400/80":e<400?"text-yellow-400/80":e<500?"text-orange-400/80":"text-red-400/80"}function Xr(e,t=80){return e.length>t?e.slice(0,t-3)+"...":e}function gp(e,t=150){return Xr(e.replace(/\n/g," ").replace(/\r/g,"").replace(/\t/g," "),t)}function bp(e,t){const r=e.split(` +`))if(!(s===i7||s===a7))if(s.startsWith(i_))a(),r={kind:"add",path:s.slice(i_.length).trim(),oldLines:[],newLines:[]};else if(s.startsWith(a_))a(),r={kind:"update",path:s.slice(a_.length).trim(),oldLines:[],newLines:[]};else if(s.startsWith(s_))a(),r={kind:"delete",path:s.slice(s_.length).trim(),oldLines:[],newLines:[]};else if((r==null?void 0:r.kind)==="update"){if(s.startsWith("@@"))continue;s.startsWith("-")&&!s.startsWith("---")?r.oldLines.push(s.slice(1)):s.startsWith("+")&&!s.startsWith("+++")&&r.newLines.push(s.slice(1))}else(r==null?void 0:r.kind)==="add"&&(s.startsWith("+")?r.newLines.push(s.slice(1)):s.trim()&&r.newLines.push(s));return a(),t}function c7({op:e}){const t=s7[e.kind]??"file",r=e.oldLines.length+e.newLines.length,a=r>hu,s=a&&r>0?Math.round(hu*(e.oldLines.length/r)):e.oldLines.length,o=a?hu-s:e.newLines.length;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2",children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm shrink-0",children:t}),e.path&&g.jsx("span",{className:"text-[#888] font-mono text-[13px] break-all",children:fg(e.path)})]}),(e.oldLines.length>0||e.newLines.length>0)&&g.jsxs("div",{className:"font-mono text-[13px] leading-relaxed mt-1.5",children:[e.oldLines.slice(0,s).map((c,d)=>g.jsxs("div",{className:"text-red-400/60",children:[g.jsx("span",{className:"select-none text-red-400/30 mr-1",children:"-"}),c]},`o${d}`)),e.newLines.slice(0,o).map((c,d)=>g.jsxs("div",{className:"text-emerald-400/60",children:[g.jsx("span",{className:"select-none text-emerald-400/30 mr-1",children:"+"}),c]},`n${d}`)),a&&g.jsxs("div",{className:"text-[#444] mt-0.5",children:["... ",r-hu," more lines"]})]})]})}function u7({args:e,result:t,status:r}){const a=o7(l7(e));return a.length===0?g.jsxs("div",{children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm",children:"patch"}),r==="failed"&&typeof t=="string"&&t.trim()&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1",children:t.trim()})]}):g.jsxs("div",{className:"space-y-2",children:[a.map((s,o)=>g.jsx(c7,{op:s},o)),r==="failed"&&typeof t=="string"&&t.trim()&&g.jsx("div",{className:"text-red-400/70 text-[13px]",children:t.trim()})]})}const d7=/data:image\/(png|jpe?g|gif|webp);base64,([A-Za-z0-9+/]+={0,2})/;function f7(e){let t=null;if(typeof e=="string")t=e;else if(e&&typeof e=="object"){const a=e;typeof a.image_url=="string"?t=a.image_url:typeof a.url=="string"&&(t=a.url)}if(!t)return null;const r=d7.exec(t);return!r||r[2].length<100||r[2].length%4!==0?null:`data:image/${r[1]};base64,${r[2]}`}function h7({args:e,result:t}){const r=(e.path??"").trim(),a=f7(t);let s=null;if(!a&&typeof t=="string"){const o=t.trim();o&&!o.toLowerCase().startsWith("data:image/")&&!o.startsWith("{")&&(s=o)}return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-baseline gap-2",children:[g.jsx("span",{className:"text-sky-400/80 font-semibold text-sm shrink-0",children:"view image"}),r&&g.jsx("span",{className:"text-[#888] font-mono text-[13px] break-all",children:fg(r)})]}),a&&g.jsx("img",{src:a,alt:r||"Tool image output",className:"mt-1.5 max-w-full max-h-96 rounded-lg border border-white/[0.06] object-contain"}),s&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1",children:s})]})}const m7={critical:"text-red-400",high:"text-orange-400",medium:"text-yellow-400",low:"text-blue-400",info:"text-cyan-400"};function p7({args:e,result:t}){const r=e.title??"",a=e.description??"",s=e.impact??"",o=e.target??"",c=e.endpoint??"",d=e.method??"",h=e.technical_analysis??"",f=e.poc_description??"",{language:m,code:p}=oE(e.poc_script_code??""),y=e.remediation_steps??"",x=e.cve??"",_=e.cwe??"",N=t,S=(N&&typeof N=="object"?N.severity:null)??e.severity??"medium",w=String(S).toLowerCase(),k=(N&&typeof N=="object"?N.cvss_score:null)??e.cvss??null,E=m7[w]??"text-yellow-400";return g.jsxs("div",{className:"space-y-3",children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[g.jsx("span",{className:`font-semibold text-sm ${E}`,children:w.toUpperCase()}),k!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["CVSS ",k]}),x&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:x}),_&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:_})]}),r&&g.jsx("div",{className:"text-[15px] text-white/80 font-semibold",children:r}),(o||c)&&g.jsxs("div",{className:"text-[13px] text-[#888] font-mono",children:[o,c?` ${d} ${c}`:""]}),a&&g.jsx(An,{text:a,maxLines:20}),s&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Impact"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:s,maxLines:15})})]}),h&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Technical Analysis"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:h,maxLines:20})})]}),(f||p)&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Proof of Concept"}),f&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:f})}),p&&g.jsx(lE,{className:m?`language-${m}`:void 0,children:p})]}),y&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Remediation"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:y,maxLines:15})})]})]})}const g7={critical:"text-red-400",high:"text-orange-400",medium:"text-yellow-400",low:"text-blue-400",info:"text-cyan-400",none:"text-[#888]"};function b7(e){if(!e.agent_name&&!e.by_you)return null;const t=e.by_you?"you":e.agent_name;return g.jsxs("span",{className:"text-[#666] text-xs ml-1.5",children:["(",t,")"]})}function jm(e){const t=String(e??"").toLowerCase(),r=g7[t]??"text-yellow-400";return g.jsx("span",{className:`font-semibold text-[13px] ${r}`,children:t.toUpperCase()||"β€”"})}function l_({toolName:e,result:t}){const r=t,a=r!=null&&typeof r=="object"&&r.success===!0;if(e==="get_report"){const f=a?r.report:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:"text-red-400/80 font-semibold text-sm",children:"report"}),f?g.jsxs("div",{className:"mt-1.5 space-y-2",children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[jm(f.severity),f.cvss!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["CVSS ",f.cvss]}),f.id&&g.jsx("span",{className:"text-[#555] font-mono text-[13px]",children:f.id}),f.cve&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:f.cve}),f.cwe&&g.jsx("span",{className:"text-[#888] font-mono text-[13px]",children:f.cwe}),(f.agent_name||f.by_you)&&g.jsx("span",{className:"text-[#666] text-[13px]",children:f.by_you?"you":f.agent_name})]}),f.title&&g.jsx("div",{className:"text-[15px] text-white/80 font-semibold",children:f.title}),(f.target||f.endpoint)&&g.jsxs("div",{className:"text-[13px] text-[#888] font-mono",children:[f.target,f.endpoint?` ${f.method??""} ${f.endpoint}`:""]}),f.description&&g.jsx(An,{text:f.description,maxLines:20})]}):g.jsx("div",{className:"mt-1 text-[#555] text-xs",children:r&&typeof r=="object"&&r.error||"Report not found"})]})}const s=a?r.reports:null,o=Array.isArray(s)?s:[],c=a&&typeof r.total_count=="number"?r.total_count:o.length,d=a&&r.severity_counts&&typeof r.severity_counts=="object"?r.severity_counts:{},h=Object.entries(d);return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[g.jsx("span",{className:"text-red-400/80 font-semibold text-sm",children:"reports"}),g.jsxs("span",{className:"text-[#555] text-[13px]",children:["(",c,")"]}),h.map(([f,m])=>g.jsxs("span",{className:"text-[13px]",children:[jm(f),g.jsx("span",{className:"text-[#888] ml-0.5",children:m})]},f))]}),o.length>0?g.jsx("div",{className:"mt-1.5 space-y-1",children:o.map((f,m)=>g.jsxs("div",{className:"text-[13px]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"-"}),jm(f.severity),f.id&&g.jsx("span",{className:"text-[#555] font-mono ml-1.5",children:f.id}),g.jsx("span",{className:"text-[#999] ml-1.5",children:f.title??"(untitled)"}),b7(f),(f.target||f.endpoint)&&g.jsxs("div",{className:"ml-3 text-[#666] font-mono text-xs",children:[f.target,f.endpoint?` ${f.method??""} ${f.endpoint}`:""]}),f.description_preview&&g.jsx("div",{className:"ml-3",children:g.jsx(ua,{text:f.description_preview})})]},f.id??m))}):g.jsx("div",{className:"mt-1 text-[#555] text-xs",children:"No reports filed yet"})]})}const eS=200,tS={GET:"text-emerald-400/80",POST:"text-blue-400/80",PUT:"text-yellow-400/80",PATCH:"text-orange-400/80",DELETE:"text-red-400/80"};function hg(e){return e<300?"text-emerald-400/80":e<400?"text-yellow-400/80":e<500?"text-orange-400/80":"text-red-400/80"}function Xr(e,t=80){return e.length>t?e.slice(0,t-3)+"...":e}function gp(e,t=150){return Xr(e.replace(/\n/g," ").replace(/\r/g,"").replace(/\t/g," "),t)}function bp(e,t){const r=e.split(` `),a=r.slice(0,t).map(s=>Xr(s,eS-5)).join(` `);return r.length>t?a+` -...`:a}function g7({args:e,result:t}){const r=e.httpql_filter??"",a=t,s=a?a.requests:null,o=Array.isArray(s)?s:[];return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"listing requests"}),r&&g.jsx("span",{className:"text-[#888] text-[13px]",children:Xr(r,150)})]}),o.length>0&&g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:[o.slice(0,20).map((c,d)=>{const h=(c.method??"GET").toUpperCase(),f=c.host??"",m=c.path??"",p=c.response,y=(p==null?void 0:p.statusCode)??null;return g.jsxs("div",{className:"flex gap-2",children:[g.jsx("span",{className:`w-10 shrink-0 font-bold ${tS[h]??"text-[#888]"}`,children:h}),g.jsx("span",{className:"text-[#777] truncate",children:Xr(f+m,180)}),y!=null&&g.jsx("span",{className:`ml-auto shrink-0 ${hg(y)}`,children:y})]},d)}),o.length>20&&g.jsxs("div",{className:"text-[#555]",children:["... +",o.length-20," more"]})]})]})}function b7({args:e,result:t}){const r=e.request_id,a=e.part??"request",s=e.search_pattern??"",o=t,c=o?o.matches:null,d=Array.isArray(c)?c:[],h=o?o.content??null:null,f=o?!!o.has_more:!1;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsxs("span",{className:"text-purple-400/80 font-semibold text-sm",children:[s?"searching":"viewing"," ",a]}),r!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",r]}),s&&g.jsxs("span",{className:"text-[#666] font-mono text-[13px]",children:["/",Xr(s,100),"/"]})]}),d.length>0&&g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-1",children:[d.slice(0,5).map((m,p)=>{const y=(m.before??"").replace(/\n/g," ").replace(/\r/g,"").slice(-100),x=(m.after??"").replace(/\n/g," ").replace(/\r/g,"").slice(0,100);return g.jsxs("div",{children:[y&&g.jsxs("span",{className:"text-[#555]",children:["...",y]}),g.jsx("span",{className:"text-amber-400/80 font-bold",children:m.match}),x&&g.jsxs("span",{className:"text-[#555]",children:[x,"..."]})]},p)}),d.length>5&&g.jsxs("div",{className:"text-[#555]",children:["... +",d.length-5," more matches"]})]}),h&&!d.length&&(()=>{const m=h.split(` +...`:a}function x7({args:e,result:t}){const r=e.httpql_filter??"",a=t,s=a?a.requests:null,o=Array.isArray(s)?s:[];return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"listing requests"}),r&&g.jsx("span",{className:"text-[#888] text-[13px]",children:Xr(r,150)})]}),o.length>0&&g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:[o.slice(0,20).map((c,d)=>{const h=(c.method??"GET").toUpperCase(),f=c.host??"",m=c.path??"",p=c.response,y=(p==null?void 0:p.statusCode)??null;return g.jsxs("div",{className:"flex gap-2",children:[g.jsx("span",{className:`w-10 shrink-0 font-bold ${tS[h]??"text-[#888]"}`,children:h}),g.jsx("span",{className:"text-[#777] truncate",children:Xr(f+m,180)}),y!=null&&g.jsx("span",{className:`ml-auto shrink-0 ${hg(y)}`,children:y})]},d)}),o.length>20&&g.jsxs("div",{className:"text-[#555]",children:["... +",o.length-20," more"]})]})]})}function y7({args:e,result:t}){const r=e.request_id,a=e.part??"request",s=e.search_pattern??"",o=t,c=o?o.matches:null,d=Array.isArray(c)?c:[],h=o?o.content??null:null,f=o?!!o.has_more:!1;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsxs("span",{className:"text-purple-400/80 font-semibold text-sm",children:[s?"searching":"viewing"," ",a]}),r!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",r]}),s&&g.jsxs("span",{className:"text-[#666] font-mono text-[13px]",children:["/",Xr(s,100),"/"]})]}),d.length>0&&g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-1",children:[d.slice(0,5).map((m,p)=>{const y=(m.before??"").replace(/\n/g," ").replace(/\r/g,"").slice(-100),x=(m.after??"").replace(/\n/g," ").replace(/\r/g,"").slice(0,100);return g.jsxs("div",{children:[y&&g.jsxs("span",{className:"text-[#555]",children:["...",y]}),g.jsx("span",{className:"text-amber-400/80 font-bold",children:m.match}),x&&g.jsxs("span",{className:"text-[#555]",children:[x,"..."]})]},p)}),d.length>5&&g.jsxs("div",{className:"text-[#555]",children:["... +",d.length-5," more matches"]})]}),h&&!d.length&&(()=>{const m=h.split(` `),p=m.slice(0,15).map(x=>Xr(x,eS)).join(` `),y=f||m.length>15;return g.jsx(wi,{className:"text-[#666]",children:p+(y?` -... more content available`:"")})})()]})}function x7({args:e,result:t}){const r=(e.method??"GET").toUpperCase(),a=e.url??"",s=e.headers,o=e.body,c=typeof o=="string"?o:"",d=t,h=d?d.error??null:null,f=d?d.status_code??null:null,m=d?d.response_time_ms??null:null,p=d?d.body:null,y=typeof p=="string"?p:null;return g.jsxs("div",{children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"request"}),g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:[g.jsxs("div",{children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:">>"}),g.jsx("span",{className:`font-bold ${tS[r]??"text-[#888]"}`,children:r}),g.jsx("span",{className:"text-[#888] ml-1 break-all",children:Xr(a,180)})]}),s&&typeof s=="object"&&Object.entries(s).slice(0,5).map(([x,_])=>g.jsxs("div",{className:"text-[#555] pl-5",children:[x,": ",gp(String(_),150)]},x))]}),c&&g.jsx(wi,{className:"text-[#888]",children:bp(c,4)}),h&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1.5",children:gp(h,150)}),f!=null&&g.jsxs("div",{className:"font-mono text-[13px] mt-1.5",children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:"<<"}),g.jsx("span",{className:`font-bold ${hg(f)}`,children:f}),m!=null&&g.jsxs("span",{className:"text-[#555] ml-2",children:[m,"ms"]})]}),y&&g.jsx(wi,{className:"text-[#666]",children:bp(y,6)})]})}function y7({args:e,result:t}){const r=e.request_id,a=e.modifications,s=t,o=s?s.status_code??null:null,c=s?s.response_time_ms??null:null,d=s?s.body:null,h=typeof d=="string"?d:null;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"repeating request"}),r!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",r]})]}),a&&typeof a=="object"&&Object.keys(a).length>0&&g.jsx("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:Object.entries(a).slice(0,5).map(([f,m])=>g.jsxs("div",{children:[g.jsxs("span",{className:"text-orange-400/60",children:[f,":"]})," ",g.jsx("span",{className:"text-[#777]",children:gp(typeof m=="string"?m:JSON.stringify(m),150)})]},f))}),o!=null&&g.jsxs("div",{className:"font-mono text-[13px] mt-1.5",children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:"<<"}),g.jsx("span",{className:`font-bold ${hg(o)}`,children:o}),c!=null&&g.jsxs("span",{className:"text-[#555] ml-2",children:[c,"ms"]})]}),h&&g.jsx(wi,{className:"text-[#666]",children:bp(h,5)})]})}const v7={get:"getting",list:"listing",create:"creating",update:"updating",delete:"deleting"};function _7({args:e}){const t=e.action??"",r=e.scope_name??"",a=v7[t]??(t||"managing");return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsxs("span",{className:"text-purple-400/80 font-semibold text-sm",children:[a," proxy scope"]}),r&&g.jsx("span",{className:"text-[#888] text-[13px]",children:Xr(r,50)})]})}function w7({args:e}){const t=e.parent_id;return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"listing sitemap"}),t&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["under #",Xr(String(t),20)]})]})}function E7({args:e}){const t=e.entry_id;return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"viewing sitemap entry"}),t&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",Xr(String(t),20)]})]})}function N7(e){switch(e.toolName){case"list_requests":return g.jsx(g7,{...e});case"view_request":return g.jsx(b7,{...e});case"send_request":return g.jsx(x7,{...e});case"repeat_request":return g.jsx(y7,{...e});case"scope_rules":return g.jsx(_7,{...e});case"list_sitemap":return g.jsx(w7,{...e});case"view_sitemap_entry":return g.jsx(E7,{...e});default:return g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:e.toolName.replace(/_/g," ")})}}function S7({args:e}){const t=e.thought??e.content??"";return t?g.jsxs("div",{children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"Agent is thinking"}),g.jsx("div",{className:"mt-1.5 italic text-[#888]",children:g.jsx(An,{text:t,maxLines:20})})]}):null}function k7({toolName:e,args:t}){if(e==="create_agent"){const r=t.name??t.agent_name??"",a=t.task??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"spawning"}),r&&g.jsx("span",{className:"text-cyan-400 font-semibold text-sm",children:r})]}),a&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:a,maxLines:15})})]})}if(e==="agent_finish"){const r=t.result_summary??"",a=t.success,s=t.findings,o=Array.isArray(s)?s:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:`font-semibold text-sm ${a===!1?"text-red-400/80":"text-emerald-400/80"}`,children:a===!1?"Agent failed":"Agent completed"}),r&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:r,maxLines:20})}),o&&o.length>0&&g.jsx("div",{className:"mt-1.5 space-y-0.5",children:o.map((c,d)=>g.jsxs("div",{className:"text-[13px] text-[#888]",children:[g.jsx("span",{className:"text-red-400/50 mr-1",children:"β€’"}),typeof c=="string"?c:JSON.stringify(c)]},d))})]})}if(e==="send_message_to_agent"){const r=t.message??"",a=t.target_agent_id??t.agent_id??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"message"}),a&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["to ",a.slice(0,16)]})]}),r&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:r,maxLines:20})})]})}if(e==="wait_for_agents"){const r=t.reason??"";return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"waiting"}),r&&g.jsx("span",{className:"text-[#888] text-[13px] truncate",children:r})]})}if(e==="stop_agent"){const r=t.target_agent_id??"",a=t.cascade!==!1,s=t.reason??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[g.jsx("span",{className:"text-red-400/80 font-semibold text-sm",children:"stopping"}),r&&g.jsx("span",{className:"text-[#888] text-[13px]",children:r.slice(0,16)}),a&&g.jsx("span",{className:"text-[#555] text-[13px] italic",children:"+ descendants"})]}),s&&g.jsx("div",{className:"mt-1.5 text-[#888] text-[13px]",children:s})]})}return e==="view_agent_graph"?g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"viewing agents graph"}):g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:e.replace(/_/g," ")})}function C7({args:e,result:t}){const r=e.query??e.search_query??"",a=t,s=a?a.content??null:null,o=a&&!a.success?a.message??null:null;return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"Searching the web"}),r&&g.jsx("div",{className:"text-[#888] text-[13px] mt-0.5",children:r}),o&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1.5",children:o}),s&&g.jsx("div",{className:"mt-2",children:g.jsx(An,{text:s,maxLines:15})})]})}const T7=50,o_=200,c_=25,u_=24,A7=/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)/g,M7=/\.\.\. \[(stdout|stderr|result|output|error) truncated at \d+k? chars\]/g;function O7(e){return e.replace(A7,"")}function Dm(e){const t=O7(e);return t.length>o_?t.slice(0,o_-3)+"...":t}function R7(e){return e.replace(M7,"").trim()}function j7(e){const t=e.split(` -`);if(t.length<=T7)return t.map(Dm).join(` +... more content available`:"")})})()]})}function v7({args:e,result:t}){const r=(e.method??"GET").toUpperCase(),a=e.url??"",s=e.headers,o=e.body,c=typeof o=="string"?o:"",d=t,h=d?d.error??null:null,f=d?d.status_code??null:null,m=d?d.response_time_ms??null:null,p=d?d.body:null,y=typeof p=="string"?p:null;return g.jsxs("div",{children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"request"}),g.jsxs("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:[g.jsxs("div",{children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:">>"}),g.jsx("span",{className:`font-bold ${tS[r]??"text-[#888]"}`,children:r}),g.jsx("span",{className:"text-[#888] ml-1 break-all",children:Xr(a,180)})]}),s&&typeof s=="object"&&Object.entries(s).slice(0,5).map(([x,_])=>g.jsxs("div",{className:"text-[#555] pl-5",children:[x,": ",gp(String(_),150)]},x))]}),c&&g.jsx(wi,{className:"text-[#888]",children:bp(c,4)}),h&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1.5",children:gp(h,150)}),f!=null&&g.jsxs("div",{className:"font-mono text-[13px] mt-1.5",children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:"<<"}),g.jsx("span",{className:`font-bold ${hg(f)}`,children:f}),m!=null&&g.jsxs("span",{className:"text-[#555] ml-2",children:[m,"ms"]})]}),y&&g.jsx(wi,{className:"text-[#666]",children:bp(y,6)})]})}function _7({args:e,result:t}){const r=e.request_id,a=e.modifications,s=t,o=s?s.status_code??null:null,c=s?s.response_time_ms??null:null,d=s?s.body:null,h=typeof d=="string"?d:null;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"repeating request"}),r!=null&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",r]})]}),a&&typeof a=="object"&&Object.keys(a).length>0&&g.jsx("div",{className:"mt-1.5 font-mono text-[13px] space-y-0.5",children:Object.entries(a).slice(0,5).map(([f,m])=>g.jsxs("div",{children:[g.jsxs("span",{className:"text-orange-400/60",children:[f,":"]})," ",g.jsx("span",{className:"text-[#777]",children:gp(typeof m=="string"?m:JSON.stringify(m),150)})]},f))}),o!=null&&g.jsxs("div",{className:"font-mono text-[13px] mt-1.5",children:[g.jsx("span",{className:"text-[#555] select-none mr-1",children:"<<"}),g.jsx("span",{className:`font-bold ${hg(o)}`,children:o}),c!=null&&g.jsxs("span",{className:"text-[#555] ml-2",children:[c,"ms"]})]}),h&&g.jsx(wi,{className:"text-[#666]",children:bp(h,5)})]})}const w7={get:"getting",list:"listing",create:"creating",update:"updating",delete:"deleting"};function E7({args:e}){const t=e.action??"",r=e.scope_name??"",a=w7[t]??(t||"managing");return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsxs("span",{className:"text-purple-400/80 font-semibold text-sm",children:[a," proxy scope"]}),r&&g.jsx("span",{className:"text-[#888] text-[13px]",children:Xr(r,50)})]})}function N7({args:e}){const t=e.parent_id;return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"listing sitemap"}),t&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["under #",Xr(String(t),20)]})]})}function S7({args:e}){const t=e.entry_id;return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"viewing sitemap entry"}),t&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["#",Xr(String(t),20)]})]})}function k7(e){switch(e.toolName){case"list_requests":return g.jsx(x7,{...e});case"view_request":return g.jsx(y7,{...e});case"send_request":return g.jsx(v7,{...e});case"repeat_request":return g.jsx(_7,{...e});case"scope_rules":return g.jsx(E7,{...e});case"list_sitemap":return g.jsx(N7,{...e});case"view_sitemap_entry":return g.jsx(S7,{...e});default:return g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:e.toolName.replace(/_/g," ")})}}function C7({args:e}){const t=e.thought??e.content??"";return t?g.jsxs("div",{children:[g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:"Agent is thinking"}),g.jsx("div",{className:"mt-1.5 italic text-[#888]",children:g.jsx(An,{text:t,maxLines:20})})]}):null}function T7({toolName:e,args:t}){if(e==="create_agent"){const r=t.name??t.agent_name??"",a=t.task??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"spawning"}),r&&g.jsx("span",{className:"text-cyan-400 font-semibold text-sm",children:r})]}),a&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:a,maxLines:15})})]})}if(e==="agent_finish"){const r=t.result_summary??"",a=t.success,s=t.findings,o=Array.isArray(s)?s:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:`font-semibold text-sm ${a===!1?"text-red-400/80":"text-emerald-400/80"}`,children:a===!1?"Agent failed":"Agent completed"}),r&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:r,maxLines:20})}),o&&o.length>0&&g.jsx("div",{className:"mt-1.5 space-y-0.5",children:o.map((c,d)=>g.jsxs("div",{className:"text-[13px] text-[#888]",children:[g.jsx("span",{className:"text-red-400/50 mr-1",children:"β€’"}),typeof c=="string"?c:JSON.stringify(c)]},d))})]})}if(e==="send_message_to_agent"){const r=t.message??"",a=t.target_agent_id??t.agent_id??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"message"}),a&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["to ",a.slice(0,16)]})]}),r&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:r,maxLines:20})})]})}if(e==="wait_for_agents"){const r=t.reason??"";return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"waiting"}),r&&g.jsx("span",{className:"text-[#888] text-[13px] truncate",children:r})]})}if(e==="stop_agent"){const r=t.target_agent_id??"",a=t.cascade!==!1,s=t.reason??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap",children:[g.jsx("span",{className:"text-red-400/80 font-semibold text-sm",children:"stopping"}),r&&g.jsx("span",{className:"text-[#888] text-[13px]",children:r.slice(0,16)}),a&&g.jsx("span",{className:"text-[#555] text-[13px] italic",children:"+ descendants"})]}),s&&g.jsx("div",{className:"mt-1.5 text-[#888] text-[13px]",children:s})]})}return e==="view_agent_graph"?g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:"viewing agents graph"}):g.jsx("span",{className:"text-cyan-400/80 font-semibold text-sm",children:e.replace(/_/g," ")})}function A7({args:e,result:t}){const r=e.query??e.search_query??"",a=t,s=a?a.content??null:null,o=a&&!a.success?a.message??null:null;return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"Searching the web"}),r&&g.jsx("div",{className:"text-[#888] text-[13px] mt-0.5",children:r}),o&&g.jsx("div",{className:"text-red-400/70 text-[13px] mt-1.5",children:o}),s&&g.jsx("div",{className:"mt-2",children:g.jsx(An,{text:s,maxLines:15})})]})}const M7=50,o_=200,c_=25,u_=24,O7=/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)/g,R7=/\.\.\. \[(stdout|stderr|result|output|error) truncated at \d+k? chars\]/g;function j7(e){return e.replace(O7,"")}function Dm(e){const t=j7(e);return t.length>o_?t.slice(0,o_-3)+"...":t}function D7(e){return e.replace(R7,"").trim()}function L7(e){const t=e.split(` +`);if(t.length<=M7)return t.map(Dm).join(` `);const r=t.length-c_-u_;return[...t.slice(0,c_).map(Dm),`... ${r} lines truncated ...`,...t.slice(-u_).map(Dm)].join(` -`)}function D7({args:e,result:t}){const r=e.action??"",a=e.code??e.script??"",s=t;let o=null;s&&typeof s=="object"?o=typeof s.stdout=="string"?s.stdout:null:typeof s=="string"&&(o=s);const c=r==="new_session"?"new session":r==="close"?"close session":r==="list_sessions"?"list sessions":null,d=o?j7(R7(o)):null;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-yellow-400/80 font-semibold text-sm",children:"Python"}),c&&g.jsx("span",{className:"text-[#888] text-[13px]",children:c})]}),a&&g.jsx(dg,{code:a,language:"python",collapsible:!0}),d&&g.jsx(wi,{className:"text-[#666]",children:d})]})}function L7({args:e}){const t=e.targets,a=(Array.isArray(t)?t:[]).map(s=>typeof s=="object"&&s?s.original??null:null).filter(Boolean);return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Starting penetration test"}),a.length===1&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["on ",a[0]]})]}),a.length>1&&g.jsx("div",{className:"mt-1.5 space-y-0.5",children:a.map((s,o)=>g.jsxs("div",{className:"text-[13px] text-[#888]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"β€’"}),s]},o))})]})}function z7({args:e}){const t=e.name??"Unknown Agent",r=e.task??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-[#888] text-[13px]",children:"subagent"}),g.jsx("span",{className:"text-purple-400 font-semibold text-sm",children:t})]}),r&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:r,maxLines:15})})]})}function I7(e){return e.toolName==="subagent_start_info"?g.jsx(z7,{...e}):g.jsx(L7,{...e})}function B7({args:e}){const t=e.executive_summary??"",r=e.methodology??"",a=e.technical_analysis??"",s=e.recommendations??"";return g.jsxs("div",{className:"space-y-3",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Penetration test completed"}),t&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Executive Summary"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:t,maxLines:25})})]}),r&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Methodology"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:r,maxLines:25})})]}),a&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Technical Analysis"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:a,maxLines:25})})]}),s&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Recommendations"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:s,maxLines:25})})]}),!t&&!r&&!a&&!s&&g.jsx("div",{className:"text-[#555] text-xs",children:"Generating final report..."})]})}function U7({toolName:e,args:t,result:r}){if(e==="create_note"){const a=t.title??"",s=t.content??"",o=t.category??"general";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note"}),g.jsxs("span",{className:"text-[#555] text-[13px]",children:["(",o,")"]})]}),a&&g.jsx("div",{className:"mt-1.5 text-[#999] text-[13px]",children:a}),s&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:s})})]})}if(e==="delete_note")return g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note removed"});if(e==="update_note"){const a=t.title??"",s=t.content??"";return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note updated"}),a&&g.jsx("div",{className:"mt-1.5 text-[#999] text-[13px]",children:a}),s&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:s})})]})}if(e==="get_note"){const a=r,s=a&&typeof a=="object"&&a.success?a.note:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note read"}),s&&g.jsxs(g.Fragment,{children:[g.jsxs("div",{className:"mt-1.5 text-[#999] text-[13px]",children:[s.title??"(untitled)",g.jsxs("span",{className:"text-[#555] ml-1",children:["(",s.category??"general",")"]}),(s.by_you||s.agent_name)&&g.jsxs("span",{className:"text-[#666] ml-1 text-xs",children:["by ",s.by_you?"you":s.agent_name]})]}),s.content&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:s.content})})]})]})}if(e==="list_notes"){const a=r;let s=[];if(a&&typeof a=="object"&&a.success){const o=a.notes;s=Array.isArray(o)?o:[]}return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"notes"}),s.length>0?g.jsx("div",{className:"mt-1.5 space-y-0.5",children:s.map((o,c)=>g.jsxs("div",{className:"text-[13px]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"-"}),g.jsx("span",{className:"text-[#999]",children:o.title??"(untitled)"}),g.jsxs("span",{className:"text-[#555] ml-1",children:["(",o.category??"general",")"]}),(o.by_you||o.agent_name)&&g.jsxs("span",{className:"text-[#666] ml-1 text-xs",children:["by ",o.by_you?"you":o.agent_name]}),o.content&&g.jsx("div",{className:"ml-3",children:g.jsx(ua,{text:o.content})})]},c))}):g.jsx("div",{className:"mt-1 text-[#555] text-xs",children:"No notes"})]})}return g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note"})}const H7={create_todo:{label:"Task added",Icon:VC},list_todos:{label:"Plan",Icon:Gk},update_todo:{label:"Task updated",Icon:qC},mark_todo_done:{label:"Task completed",Icon:R_},mark_todo_pending:{label:"Task reopened",Icon:eT},delete_todo:{label:"Task removed",Icon:hT}};function $7({status:e}){return e==="done"?g.jsx(R_,{className:"w-3.5 h-3.5 text-emerald-400/70 shrink-0"}):e==="in_progress"?g.jsx(rC,{className:"w-3.5 h-3.5 text-purple-400/70 shrink-0 animate-pulse"}):g.jsx(aC,{className:"w-3.5 h-3.5 text-[#444] shrink-0"})}function q7({todos:e,highlightId:t}){return g.jsx("div",{className:"space-y-0",children:e.map((r,a)=>{const s=r.status??"pending",o=t&&r.id===t;return g.jsxs("div",{className:`flex items-start gap-2.5 py-1.5 px-2 -mx-2 rounded-md transition-colors ${o?"bg-purple-500/[0.08]":""}`,children:[g.jsx("div",{className:"mt-[1px]",children:g.jsx($7,{status:s})}),g.jsx("span",{className:`text-[13px] leading-snug ${s==="done"?"text-[#555] line-through":s==="in_progress"?"text-[#bbb]":"text-[#999]"}`,children:r.title??"(untitled)"})]},r.id??a)})})}function P7({toolName:e,args:t,result:r}){const a=H7[e]??{label:"Plan",Icon:ZC},s=a.Icon,o=r;if(typeof o=="string"&&o.trim())return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(s,{className:"w-3.5 h-3.5 text-purple-400/60"}),g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:a.label})]}),g.jsx("div",{className:"mt-1.5 text-[#888] text-[13px]",children:o.trim()})]});let c=[],d=null,h;if(o&&typeof o=="object"){if(d=o.error??null,o.success){const m=o.todos;c=Array.isArray(m)?m:[]}h=o.id??t.todo_id??void 0}const f=e!=="list_todos"?h:void 0;return c.length===0&&!d?g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(s,{className:"w-3.5 h-3.5 text-purple-400/60"}),g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:a.label})]}):g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[g.jsx(s,{className:"w-3.5 h-3.5 text-purple-400/60"}),g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:a.label})]}),d&&g.jsx("div",{className:"text-red-400/70 text-[13px] mb-2",children:d}),c.length>0&&g.jsx("div",{className:"rounded-lg border border-white/[0.06] bg-white/[0.015] px-3 py-2",children:g.jsx(q7,{todos:c,highlightId:f})})]})}function d_(e){if(e==null)return null;if(typeof e=="string")return e.trim()?e:null;if(typeof e=="object"){const t=e;if(typeof t.__raw=="string")return t.__raw;if(Object.keys(t).length===0)return null;try{return JSON.stringify(e,null,2)}catch{return String(e)}}return String(e)}function nS({toolName:e,args:t,result:r}){const a=d_(t),s=d_(r);return g.jsxs("div",{children:[g.jsx("span",{className:"text-[#888] font-semibold text-sm",children:e.replace(/_/g," ")}),a&&g.jsx(wi,{className:"text-[#777]",children:a}),s&&g.jsx(wi,{className:"text-[#666]",children:s})]})}function F7({args:e}){const t=e.skills,r=(Array.isArray(t)?t:String(t??"").split(",")).map(a=>String(a).trim()).filter(Boolean);return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Loading skill"}),r.length>0&&g.jsx("span",{className:"text-[#888] text-[13px]",children:r.join(", ")})]})}function G7({args:e}){const t=e.message??"";return t?g.jsxs("div",{children:[g.jsx(ua,{text:t}),g.jsx("div",{className:"mt-1.5 text-[#888] text-[13px]",children:"waiting for your reply"})]}):null}const ad={terminal:{renderer:e7,icon:B_,color:"text-emerald-400"},python:{renderer:D7,icon:oC,color:"text-yellow-400"},browser:{renderer:n7,icon:z_,color:"text-blue-400"},filesystem:{renderer:r7,icon:gC,color:"text-sky-400"},proxy:{renderer:N7,icon:T_,color:"text-purple-400",match:/request|sitemap|scope/},reporting:{renderer:h7,icon:iT,color:"text-red-400"},thinking:{renderer:S7,icon:M_,color:"text-purple-400"},agents:{renderer:k7,icon:Ao,color:"text-cyan-400",match:/agent/},search:{renderer:C7,icon:nT,color:"text-amber-400"},lifecycle:{renderer:I7,icon:L_,color:"text-emerald-400"},notes:{renderer:U7,icon:uT,color:"text-amber-400",match:/note/},skills:{renderer:F7,icon:Hm,color:"text-emerald-400"},todos:{renderer:P7,icon:jC,color:"text-purple-400",match:/todo/},telemetry:{renderer:nS,icon:Hm,color:"text-[#555]"}},V7={terminal:["exec_command","write_stdin","terminal_execute"],python:["python_action"],browser:["browser_action"],filesystem:["apply_patch","view_image","str_replace_editor","list_files","search_files"],proxy:["list_requests","view_request","repeat_request","list_sitemap","view_sitemap_entry","scope_rules","send_request"],reporting:["create_vulnerability_report","list_reports","get_report"],thinking:["think"],agents:["create_agent","agent_finish","send_message_to_agent","wait_for_agents","view_agent_graph","stop_agent"],search:["web_search"],lifecycle:["scan_start_info","subagent_start_info","finish_scan","respond_to_user"],notes:["create_note","delete_note","update_note","list_notes","get_note"],skills:["load_skill"],todos:["create_todo","list_todos","update_todo","mark_todo_done","mark_todo_pending","delete_todo"],telemetry:["sandbox_error_details","llm_error_details"]},Y7=Object.fromEntries(Object.entries(V7).flatMap(([e,t])=>t.map(r=>[r,e]))),X7={finish_scan:B7,respond_to_user:G7,apply_patch:u7,view_image:d7,list_reports:l_,get_report:l_},K7={agent_finish:{icon:L_,color:"text-cyan-400"},send_message_to_agent:{icon:mh,color:"text-cyan-400"},wait_for_agents:{icon:mh,color:"text-cyan-400"},respond_to_user:{icon:mh,color:"text-emerald-400"},view_agent_graph:{icon:mC,color:"text-cyan-400"},stop_agent:{icon:A_,color:"text-red-400"},scan_start_info:{icon:dC,color:"text-emerald-400"},subagent_start_info:{icon:Ao,color:"text-purple-400"},view_image:{icon:AC,color:"text-sky-400"}},Z7=ad.telemetry;function rS(e){var r;const t=Y7[e];if(t)return t;for(const[a,s]of Object.entries(ad))if((r=s.match)!=null&&r.test(e))return a;return null}function Q7(e){const t=X7[e];if(t)return t;const r=rS(e);return r?ad[r].renderer:nS}function W7(e){const t=K7[e];if(t)return t;const r=rS(e),a=r?ad[r]:Z7;return{icon:a.icon,color:a.color}}const J7=30;function eU({role:e,content:t}){const r=e==="user"||e==="human";return g.jsxs("div",{children:[g.jsx("span",{className:`font-semibold text-sm ${r?"text-blue-400/80":"text-purple-400/80"}`,children:r?"User":"Thinking"}),g.jsx("div",{className:"mt-1.5 italic text-[#888]",children:g.jsx(An,{text:t,maxLines:J7})})]})}class tU extends ee.Component{constructor(t){super(t),this.state={hasError:!1}}static getDerivedStateFromError(){return{hasError:!0}}render(){return this.state.hasError?g.jsx("span",{className:"text-[#555] font-semibold text-sm",children:this.props.toolName.replace(/_/g," ")}):this.props.children}}function nU(e){const t=Q7(e.toolName);return g.jsx(tU,{toolName:e.toolName,children:g.jsx(t,{...e})})}function iS(e){if(e==null||typeof e!="string")return e;const t=e.trim();if(!t)return e;try{return JSON.parse(t)}catch{}try{const r=t.replace(/\bNone\b/g,"null").replace(/\bTrue\b/g,"true").replace(/\bFalse\b/g,"false").replace(/'/g,'"');return JSON.parse(r)}catch{return{__raw:e}}}function aS(e){const t=iS(e);return t&&typeof t=="object"&&!Array.isArray(t)?t:t==null?{}:{__raw:typeof t=="string"?t:JSON.stringify(t)}}function f_(e){const t=/(\d+)$/.exec(e);return t?parseInt(t[1],10):0}function mg(e){var r;const t=(r=e.data)==null?void 0:r.role;return e.type==="chat"&&(t==="user"||t==="human")}function rU(e){var t;return mg(e)&&String(((t=e.data)==null?void 0:t.content)??"").startsWith("[Message from ")}function iU(e){const t=new Set;let r=!1;for(const a of e)if(mg(a)){if(rU(a)){t.add(a.id);continue}r||(r=!0,t.add(a.id))}return t}const aU={completed:"text-emerald-400 border-emerald-500/30 bg-emerald-500/10",running:"text-blue-400 border-blue-500/30 bg-blue-500/10",waiting:"text-yellow-400 border-yellow-500/30 bg-yellow-500/10",stopped:"text-[#aaa] border-[#333] bg-[#1a1a1a]",crashed:"text-red-400 border-red-500/30 bg-red-500/10",failed:"text-red-400 border-red-500/30 bg-red-500/10"};function sU(e){return e==="completed"?"completed":e==="running"?"running":e==="failed"||e==="crashed"?"failed":e}function lU(e,t){var d;const r=new Map;for(const h of e)if(h.parent_id){const f=r.get(h.parent_id)??[];f.push(h.id),r.set(h.parent_id,f)}const a=new Map,s=new Map,o=new Map;for(const h of t)if(h.type==="tool"){if(a.set(h.agent_id,(a.get(h.agent_id)??0)+1),((d=h.data)==null?void 0:d.tool_name)==="create_agent"){const f=aS(h.data.args),m=f.name??f.agent_name??"",p=f.task??"";m&&p&&o.set(m,p)}}else mg(h)||s.set(h.agent_id,(s.get(h.agent_id)??0)+1);const c=new Map;for(const h of e)c.set(h.id,{id:h.id,name:h.name,task:o.get(h.name)??"",status:sU(h.status),parentId:h.parent_id,children:r.get(h.id)??[],createdAt:h.created_at,toolCount:a.get(h.id)??0,messageCount:s.get(h.id)??0});return c}function oU({agent:e,events:t,showHeader:r=!0}){const a=ee.useMemo(()=>{const c=t.filter(h=>h.agent_id===e.id).sort((h,f)=>f_(h.id)-f_(f.id)),d=iU(c);return c.filter(h=>!d.has(h.id))},[t,e.id]),s=a.filter(c=>c.type==="tool").length,o=a.length-s;return g.jsxs("div",{children:[r&&g.jsxs(g.Fragment,{children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap mb-1",children:[g.jsx("span",{className:"text-base font-semibold text-white truncate",children:e.name}),g.jsx("span",{className:`flex-shrink-0 text-xs font-medium capitalize px-2 py-0.5 rounded-full border ${aU[e.status]??"text-[#aaa] border-[#333] bg-[#1a1a1a]"}`,children:e.status}),g.jsx("span",{className:"font-mono text-xs text-[#555]",children:e.id})]}),g.jsxs("p",{className:"text-xs text-[#666] mb-4",children:[o," message",o===1?"":"s"," Β· ",s," tool call",s===1?"":"s"]})]}),a.length===0?g.jsx("p",{className:"text-sm text-[#666]",children:"No recorded activity for this agent."}):g.jsx("div",{className:"py-1",children:a.map((c,d)=>{var N,S,w,k,E,M;const h=d===a.length-1,f=c.type==="tool",m=f?String(((N=c.data)==null?void 0:N.tool_name)??"tool"):"",p=f?"":String(((S=c.data)==null?void 0:S.role)??"assistant");let y,x;if(f){const I=W7(m);y=I.icon,x=I.color}else{const I=p==="user"||p==="human";y=I?Ao:M_,x=I?"text-blue-400":"text-purple-400"}const _=f?String(((w=c.data)==null?void 0:w.status)??"completed"):"completed";return g.jsxs("div",{className:"flex gap-3",children:[g.jsxs("div",{className:"flex flex-col items-center shrink-0",children:[g.jsx("div",{className:`w-[30px] h-[30px] rounded-full bg-black border flex items-center justify-center shrink-0 ${f&&_==="running"?"border-blue-500/40 animate-pulse":f&&_==="failed"?"border-red-500/30":"border-[#222]"}`,children:g.jsx(y,{className:`w-3.5 h-3.5 ${x}`})}),!h&&g.jsx("div",{className:"w-px flex-1 bg-[#1a1a1a] mt-1"})]}),g.jsx("div",{className:"flex-1 min-w-0 pt-[5px] pb-6",children:f?g.jsx(nU,{toolName:m,args:aS((k=c.data)==null?void 0:k.args),result:iS((E=c.data)==null?void 0:E.result)??null,status:_}):g.jsx(eU,{role:p,content:String(((M=c.data)==null?void 0:M.content)??"")})})]},c.id)})})]})}class Iu extends Error{constructor(t){super(t),this.name="RunParseError"}}const cU=["critical","high","medium","low"];function uU(e){const t=String(e??"").toLowerCase().trim();return cU.includes(t)?t:"low"}function dU(e){if(typeof e=="string"&&e.trim()){const t=e.trim().replace(" UTC","Z").replace(" ","T"),r=new Date(t);if(!Number.isNaN(r.getTime()))return r.toISOString();const a=new Date(e);if(!Number.isNaN(a.getTime()))return a.toISOString()}return new Date().toISOString()}function Ot(e){return typeof e=="string"&&e.length>0?e:null}function fU(e){return typeof e=="number"&&Number.isFinite(e)?e:null}function sS(e,t){try{return JSON.parse(e)}catch{throw new Iu(`${t} isn't valid JSON. Make sure you selected a Strix run directory.`)}}function hU(e){const t=sS(e,"run.json");if(!t||typeof t!="object"||Array.isArray(t))throw new Iu("run.json is not an object.");const r=t,a=[],s=r.targets_info;if(Array.isArray(s)){for(const x of s)if(x&&typeof x=="object"){const _=x.original;typeof _=="string"&&_&&a.push(_)}}const o=Ot(r.start_time),c=Ot(r.end_time);let d=null;if(o&&c){const x=new Date(o).getTime(),_=new Date(c).getTime();!Number.isNaN(x)&&!Number.isNaN(_)&&_>=x&&(d=Math.round((_-x)/1e3))}let h=null,f=null,m=null,p=null;const y=r.scan_results;if(y&&typeof y=="object"){const x=y;h=Ot(x.executive_summary),f=Ot(x.technical_analysis),m=Ot(x.methodology),p=Ot(x.recommendations)}return{runId:Ot(r.run_id),runName:Ot(r.run_name),targets:a,scanMode:Ot(r.scan_mode),status:Ot(r.status),startTime:o,endTime:c,durationSeconds:d,executiveSummary:h,technicalAnalysis:f,methodology:m,recommendations:p}}function mU(){return{pr_review_id:null,cve:null,cvss:null,potential_risk_saving:null,risk_saving_description:null,impact:null,endpoint:null,method:null,target:null,technical_analysis:null,poc_description:null,poc_script_code:null,code_diff:null,code_file:null,code_before:null,code_after:null,cwe:null,code_locations:null,remediation_steps:null,fix_pr_body:null,evidence:null,assumptions:null,fix_effort:null,cvss_breakdown:null,status_changed_at:null,status_changed_by:null,status_note:null,snoozed_until:null,reopened_at:null,reopened_by:null,original_severity:null,severity_changed_at:null,severity_changed_by:null,severity_override_reason:null,retest_of_vulnerability_id:null}}function pU(e,t,r){const a=e.cwe,s=typeof a=="string"&&a.trim()?[a.trim()]:Array.isArray(a)?a.filter(c=>typeof c=="string"&&c):null;return{...mU(),id:Ot(e.id)??`vuln-${t+1}`,scan_id:r,title:Ot(e.title)??"Untitled finding",description:Ot(e.description)??"",severity:uU(e.severity),status:"open",created_at:dU(e.timestamp),cve:Ot(e.cve),cvss:fU(e.cvss),impact:Ot(e.impact),endpoint:Ot(e.endpoint),method:Ot(e.method),target:Ot(e.target),technical_analysis:Ot(e.technical_analysis),poc_description:Ot(e.poc_description),poc_script_code:Ot(e.poc_script_code),cwe:s,code_locations:Array.isArray(e.code_locations)?e.code_locations:null,remediation_steps:Ot(e.remediation_steps),fix_pr_body:Ot(e.fix_pr_body),evidence:Ot(e.evidence),assumptions:Ot(e.assumptions),fix_effort:Ot(e.fix_effort)??null,cvss_breakdown:e.cvss_breakdown??null}}function gU(e,t=null){const r=sS(e,"vulnerabilities.json");if(!Array.isArray(r))throw new Iu("vulnerabilities.json is not a JSON array.");return r.map((a,s)=>{if(!a||typeof a!="object")throw new Iu(`vulnerabilities.json entry #${s+1} is not an object.`);return pU(a,s,t)})}function bU(e){const t={critical:0,high:0,medium:0,low:0};for(const r of e)t[r.severity]+=1;return t}async function Ja(e){const t=await fetch(e,{cache:"no-store"});if(!t.ok)throw new Error(`${e} responded ${t.status}`);return t.json()}function sd(e){return e?`?run=${encodeURIComponent(e)}`:""}async function lS(e){const t=await Ja("/api/run"+sd(e)),r=hU(JSON.stringify(t)),a=t.finished===!0;return{summary:r,raw:t,finished:a}}async function oS(e,t){const r=await Ja("/api/vulnerabilities"+sd(t));return gU(JSON.stringify(r),e)}async function xU(e){const t=await Ja("/api/report"+sd(e));return(t==null?void 0:t.markdown)??null}async function cS(e){const t=await Ja("/api/transcript"+sd(e));return{agents:Array.isArray(t==null?void 0:t.agents)?t.agents:[],events:Array.isArray(t==null?void 0:t.events)?t.events:[]}}async function h_(e){const{summary:t,raw:r,finished:a}=await lS(e),[s,o,c]=await Promise.all([oS(t.runId,e).catch(()=>[]),xU(e).catch(()=>null),cS(e).catch(()=>({agents:[],events:[]}))]);return{summary:t,raw:r,finished:a,vulnerabilities:s,reportMarkdown:o,transcript:c}}async function rl(e,t){const r=await fetch(e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),cache:"no-store"});let a={};try{const s=await r.json();s&&typeof s=="object"&&(a=s)}catch{}return{ok:r.ok,status:r.status,data:a}}async function yU(){const e=await Ja("/api/runs");return{locked:(e==null?void 0:e.locked)??!0,count:typeof(e==null?void 0:e.count)=="number"?e.count:0,runs:Array.isArray(e==null?void 0:e.runs)?e.runs:[]}}async function vU(){const e=await Ja("/api/capabilities");return{can_steer:(e==null?void 0:e.can_steer)===!0}}async function _U(e,t){const{ok:r,data:a}=await rl("/api/agents/steer",{agent_id:e,message:t});return r&&a.ok===!0?{ok:!0}:{ok:!1,error:String(a.error??"unavailable")}}async function wU(e,t){const{ok:r,data:a}=await rl("/api/feedback",{message:e,email:t});return r&&a.ok===!0?{ok:!0}:{ok:!1,error:String(a.error??"unavailable")}}async function EU(){const e=await Ja("/api/auth/status");return{verified:(e==null?void 0:e.verified)===!0,email:(e==null?void 0:e.email)??null}}async function uS(e){const{ok:t,data:r}=await rl("/api/auth/otp/start",{email:e});return t&&r.ok===!0?{ok:!0}:{ok:!1,error:String(r.error??"unavailable")}}async function dS(e,t){const{ok:r,data:a}=await rl("/api/auth/otp/verify",{email:e,code:t});return r&&a.verified===!0?{verified:!0,email:String(a.email??e)}:{verified:!1,error:String(a.error??"invalid_code")}}async function NU(){await rl("/api/auth/forget",{})}async function SU(e){const{ok:t,data:r}=await rl("/api/report/send",e?{run:e}:{});return t&&r.ok===!0?{ok:!0,password:String(r.password??""),filename:String(r.filename??"strix-report.pdf")}:{ok:!1,error:String(r.error??"unavailable")}}const Bs="__root__";function fS({agents:e,fixedAgentId:t,className:r}){const a=ee.useRef(null),[s,o]=ee.useState(!1),[c,d]=ee.useState(!1),[h,f]=ee.useState(""),[m,p]=ee.useState(!1),[y,x]=ee.useState(null),_=t!=null,N=ee.useMemo(()=>e.find(z=>!z.parent_id)??e[0]??null,[e]),S=ee.useMemo(()=>e.filter(z=>z.parent_id&&z.status==="running"),[e]),[w,k]=ee.useState(Bs),[E,M]=ee.useState(!1);ee.useEffect(()=>{w!==Bs&&!S.some(z=>z.id===w)&&k(Bs)},[w,S]);const{targetId:I,targetName:R}=ee.useMemo(()=>{if(_){const V=e.find(P=>P.id===t)??null;return{targetId:t??null,targetName:(V==null?void 0:V.name)??"this agent"}}if(w===Bs)return{targetId:(N==null?void 0:N.id)??null,targetName:"Root agent"};const z=e.find(V=>V.id===w)??null;return{targetId:(z==null?void 0:z.id)??(N==null?void 0:N.id)??null,targetName:(z==null?void 0:z.name)??"Root agent"}},[e,t,_,N,w]),U=h.trim().length===0;ee.useLayoutEffect(()=>{const z=a.current;z&&(z.style.height="auto",z.style.height=`${z.scrollHeight}px`)},[h]);const B=ee.useCallback(()=>{o(!0),requestAnimationFrame(()=>{var z;return(z=a.current)==null?void 0:z.focus()})},[]),Z=ee.useCallback(()=>{o(!1),d(!1),M(!1)},[]),j=ee.useCallback(async()=>{if(m)return;const z=h.trim();if(!z||!I)return;p(!0),x(null);const V=R,P=await _U(I,z);p(!1),P.ok?(f(""),x(`Sent to ${V}`),Tr("agent_steered")):P.error==="not_delivered"?x("Could not reach that agent (it may have finished)."):x("Could not send that message. Try again.")},[m,h,I,R]);return s?g.jsxs("div",{className:Mr("mt-4 rounded-2xl border border-white/[0.08] bg-[#050505] overflow-hidden transition-colors duration-300",c?"border-white/[0.18]":"hover:border-white/[0.12]",r),children:[g.jsxs("div",{className:"flex items-center justify-between gap-3 border-b border-white/[0.06] px-5 py-3",children:[g.jsxs("div",{className:"min-w-0",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(Um,{className:"h-4 w-4 text-[#666]"}),g.jsx("p",{className:"text-sm font-medium text-white",children:"Live prompt"})]}),g.jsx("p",{className:"mt-0.5 text-xs text-[#777]",children:"Connected"})]}),g.jsxs("div",{className:"flex shrink-0 items-center gap-2",children:[_?g.jsxs("div",{className:"rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-xs text-[#aaa]",children:["Target: ",g.jsx("span",{className:"text-white",children:R})]}):g.jsxs("div",{className:"flex items-center gap-1.5",children:[g.jsx("span",{className:"text-xs text-[#aaa]",children:"Target:"}),g.jsxs("div",{className:"relative",children:[g.jsxs("button",{type:"button",onClick:()=>M(z=>!z),onBlur:()=>requestAnimationFrame(()=>M(!1)),className:"inline-flex h-7 items-center gap-1 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 text-xs text-white transition-colors hover:border-white/[0.16] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20","aria-haspopup":"listbox","aria-expanded":E,children:[g.jsx("span",{className:"max-w-[140px] truncate",children:R}),g.jsx(ho,{className:"h-3.5 w-3.5 text-[#999]"})]}),E&&g.jsxs("div",{className:"absolute right-0 z-10 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-[#333] bg-[#0a0a0a] py-1 shadow-xl",role:"listbox",children:[g.jsx(m_,{label:"Root agent",active:w===Bs,onSelect:()=>{k(Bs),M(!1)}}),S.map(z=>g.jsx(m_,{label:z.name,active:w===z.id,onSelect:()=>{k(z.id),M(!1)}},z.id))]})]})]}),g.jsx("button",{type:"button",onClick:Z,className:"inline-flex h-7 w-7 items-center justify-center rounded-full text-[#777] transition-colors hover:bg-white/[0.06] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20","aria-label":"Collapse live prompt composer",children:g.jsx(ho,{className:"h-4 w-4"})})]})]}),g.jsx("div",{className:"px-5 pt-4 pb-3",children:g.jsx("textarea",{ref:a,rows:1,value:h,onChange:z=>f(z.target.value),onFocus:()=>d(!0),onBlur:()=>d(!1),onKeyDown:z=>{z.key==="Enter"&&!z.shiftKey&&(z.preventDefault(),j())},placeholder:"Send a live prompt to the running pentest…",maxLength:4e3,disabled:m,className:"block w-full resize-none border-0 bg-transparent p-0 text-[15px] leading-6 text-white placeholder:text-[#444] focus:outline-none disabled:opacity-60 max-h-[160px] overflow-y-auto"})}),g.jsxs("div",{className:"flex items-center justify-between gap-3 px-4 pb-4",children:[g.jsx("div",{className:"text-xs text-[#666]",children:y??"Press Enter to send."}),g.jsxs("button",{type:"button",onClick:z=>{z.stopPropagation(),j()},disabled:m||U,className:Mr("inline-flex h-10 min-w-[112px] items-center justify-center gap-2 rounded-full px-4 text-sm font-medium transition-colors",m||U?"bg-white/[0.08] text-[#666]":"bg-white text-black hover:bg-neutral-200"),children:[m?g.jsx(qs,{className:"h-4 w-4 animate-spin"}):g.jsx(zk,{className:"h-4 w-4",strokeWidth:2.5}),g.jsx("span",{children:"Send prompt"})]})]})]}):g.jsxs("button",{type:"button",onClick:B,className:Mr("mt-4 flex w-full items-center justify-between gap-3 rounded-2xl border border-white/[0.08] bg-[#050505] px-5 py-3 text-left transition-colors duration-300 hover:border-white/[0.12] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20",r),"aria-expanded":!1,"aria-label":"Expand live prompt composer",children:[g.jsxs("div",{className:"flex min-w-0 items-center gap-2",children:[g.jsx(Um,{className:"h-4 w-4 shrink-0 text-[#666]"}),g.jsx("span",{className:"truncate text-sm font-medium text-white",children:"Guide the agent"})]}),g.jsx(O_,{className:"h-4 w-4 shrink-0 text-[#777]"})]})}function m_({label:e,active:t,onSelect:r}){return g.jsx("button",{type:"button",role:"option","aria-selected":t,onMouseDown:a=>{a.preventDefault(),r()},className:Mr("block w-full truncate px-3 py-1.5 text-left text-xs transition-colors hover:bg-white/[0.06]",t?"text-white":"text-[#aaa]"),children:e})}const kU={completed:"bg-emerald-400",running:"bg-blue-400",waiting:"bg-yellow-400",stopped:"bg-[#888]",crashed:"bg-red-400",failed:"bg-red-400"},CU=80;function TU({open:e,agent:t,events:r,steerable:a,onClose:s}){const o=ee.useRef(null),c=ee.useRef(!1),[d,h]=ee.useState(e),[f,m]=ee.useState(e?"open":"closed"),[p,y]=ee.useState(!1),x=ee.useRef(t);ee.useEffect(()=>{t&&(x.current=t)},[t]);const _=t??x.current;ee.useEffect(()=>{if(e){h(!0),m("open");return}m("closed");const S=setTimeout(()=>h(!1),140);return()=>clearTimeout(S)},[e]),ee.useEffect(()=>{if(!d){y(!1);return}const S=requestAnimationFrame(()=>y(!0));return()=>cancelAnimationFrame(S)},[d]);const N=ee.useCallback(()=>{const S=o.current;S&&(c.current=S.scrollHeight-S.scrollTop-S.clientHeight{const S=o.current;!S||!c.current||requestAnimationFrame(()=>{S.scrollTo({top:S.scrollHeight,behavior:"smooth"})})},[r]),ee.useEffect(()=>{if(!d)return;const S=k=>{k.key==="Escape"&&s()};document.addEventListener("keydown",S);const w=document.body.style.overflow;return document.body.style.overflow="hidden",()=>{document.removeEventListener("keydown",S),document.body.style.overflow=w}},[d,s]),!d||!_?null:g.jsx("div",{"data-state":f,className:"agent-modal fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 sm:p-8",onClick:s,role:"dialog","aria-modal":"true","aria-label":`Agent ${_.name}`,children:g.jsxs("div",{className:"relative flex h-[60vh] w-[calc(100vw-4rem)] max-w-6xl flex-col overflow-hidden rounded-xl border border-[#222] bg-[#0a0a0a] shadow-2xl",onClick:S=>S.stopPropagation(),children:[g.jsxs("div",{className:"flex items-center justify-between gap-3 border-b border-[#222] px-5 py-3.5",children:[g.jsxs("div",{className:"flex min-w-0 items-center gap-2",children:[g.jsx("span",{className:`h-2 w-2 flex-shrink-0 rounded-full ${kU[_.status]??"bg-[#888]"}`}),g.jsx("span",{className:"truncate text-sm font-semibold text-white",children:_.name}),g.jsx("span",{className:"flex-shrink-0 font-mono text-xs text-[#555]",children:_.id})]}),g.jsx("button",{type:"button",onClick:s,"aria-label":"Close",className:"flex-shrink-0 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white",children:g.jsx(vp,{className:"h-4 w-4"})})]}),g.jsx("div",{ref:o,onScroll:N,className:"flex-1 overflow-y-auto p-5",children:p&&g.jsx(oU,{agent:_,events:r,showHeader:!1})}),a&&g.jsx("div",{className:"border-t border-[#222] px-5 py-3",children:g.jsx(fS,{agents:[_],fixedAgentId:_.id,className:"mt-0"})})]})})}var hS={color:void 0,size:void 0,className:void 0,style:void 0,attr:void 0},p_=da.createContext&&da.createContext(hS),AU=["attr","size","title"];function MU(e,t){if(e==null)return{};var r,a,s=OU(e,t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(e);for(a=0;ada.createElement(t.tag,Uu({key:r},t.attr),mS(t.child)))}function pg(e){return t=>da.createElement(LU,Bu({attr:Uu({},e.attr)},t),mS(e.child))}function LU(e){var t=r=>{var a=e.attr,s=e.size,o=e.title,c=MU(e,AU),d=s||r.size||"1em",h;return r.className&&(h=r.className),e.className&&(h=(h?h+" ":"")+e.className),da.createElement("svg",Bu({stroke:"currentColor",fill:"currentColor",strokeWidth:"0"},r.attr,a,c,{className:h,style:Uu(Uu({color:e.color||r.color},r.style),e.style),height:d,width:d,xmlns:"http://www.w3.org/2000/svg"}),o&&da.createElement("title",null,o),e.children)};return p_!==void 0?da.createElement(p_.Consumer,null,r=>t(r)):t(hS)}function zU(e){return pg({attr:{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round"},child:[{tag:"circle",attr:{cx:"5",cy:"6",r:"3"},child:[]},{tag:"path",attr:{d:"M5 9v12"},child:[]},{tag:"circle",attr:{cx:"19",cy:"18",r:"3"},child:[]},{tag:"path",attr:{d:"m15 9-3-3 3-3"},child:[]},{tag:"path",attr:{d:"M12 6h5a2 2 0 0 1 2 2v7"},child:[]}]})(e)}function IU(e){return pg({attr:{viewBox:"0 0 16 16",fill:"currentColor"},child:[{tag:"path",attr:{d:"M15 4.95703C15 4.58711 14.8563 4.24054 14.5949 3.97992L12.0096 1.39234C11.4879 0.86922 10.5788 0.86922 10.0571 1.39234L8 3.45119V3.32321C8 2.55068 7.37187 1.922 6.6 1.922H2.4C1.62813 1.922 1 2.55068 1 3.32321V13.5988C1 14.3713 1.62813 15 2.4 15H12.6667C13.4385 15 14.0667 14.3713 14.0667 13.5988V9.39514C14.0667 8.62261 13.4385 7.99393 12.6667 7.99393H12.5379L14.5949 5.93508C14.8553 5.67445 15 5.32602 15 4.95703ZM2.4 2.85521H6.6C6.85667 2.85521 7.06667 3.06446 7.06667 3.32228V7.99299H1.93333V3.32228C1.93333 3.06446 2.14333 2.85521 2.4 2.85521ZM1.93333 13.5979V8.92714H7.06667V14.0649H2.4C2.14333 14.0649 1.93333 13.8547 1.93333 13.5979ZM13.1333 9.39421V13.5979C13.1333 13.8547 12.9233 14.0649 12.6667 14.0649H8V8.92714H12.6667C12.9233 8.92714 13.1333 9.13638 13.1333 9.39421ZM8 7.99299V6.46287L9.5288 7.99299H8ZM13.9351 5.2737L11.3488 7.86221C11.1789 8.03223 10.8859 8.03223 10.716 7.86221L8.12973 5.2737C8.0448 5.18963 7.99813 5.07753 7.99813 4.95796C7.99813 4.83839 8.0448 4.7263 8.12973 4.64129L10.716 2.05278C10.8009 1.96777 10.9129 1.92106 11.0324 1.92106C11.1519 1.92106 11.2639 1.96777 11.3488 2.05278L13.9351 4.64129C14.02 4.72536 14.0667 4.83746 14.0667 4.95703C14.0667 5.0766 14.02 5.1887 13.9351 5.2737Z"},child:[]}]})(e)}function pS(e){return pg({attr:{viewBox:"0 0 512 512"},child:[{tag:"path",attr:{fill:"none",strokeLinecap:"round",strokeMiterlimit:"10",strokeWidth:"32",d:"M431 320.6c-1-3.6 1.2-8.6 3.3-12.2a34 34 0 0 1 2.1-3.1A162 162 0 0 0 464 215c.3-92.2-77.5-167-173.7-167-83.9 0-153.9 57.1-170.3 132.9a160.7 160.7 0 0 0-3.7 34.2c0 92.3 74.8 169.1 171 169.1 15.3 0 35.9-4.6 47.2-7.7s22.5-7.2 25.4-8.3a26.4 26.4 0 0 1 9.3-1.7 26 26 0 0 1 10.1 2l56.7 20.1a13.5 13.5 0 0 0 3.9 1 8 8 0 0 0 8-8 13 13 0 0 0-.5-2.7z"},child:[]},{tag:"path",attr:{fill:"none",strokeLinecap:"round",strokeMiterlimit:"10",strokeWidth:"32",d:"M66.46 232a146.23 146.23 0 0 0 6.39 152.67c2.31 3.49 3.61 6.19 3.21 8s-11.93 61.87-11.93 61.87a8 8 0 0 0 2.71 7.68A8.17 8.17 0 0 0 72 464a7.3 7.3 0 0 0 2.91-.6l56.21-22a15.7 15.7 0 0 1 12 .2c18.94 7.38 39.88 12 60.83 12A159.2 159.2 0 0 0 284 432.11"},child:[]}]})(e)}const BU=[{icon:_C,label:"PR security reviews"},{icon:lT,label:"Attack surface monitoring"},{icon:ET,label:"Real-time threat intelligence"},{icon:Pk,label:"Scheduled pentesting"},{icon:yT,label:"One-click autofix"},{icon:FC,label:"Jira, Linear & Slack integrations"}];function UU({open:e,onClose:t,description:r,source:a="sidebar"}){const[s,o]=ee.useState(e),[c,d]=ee.useState(e?"open":"closed");return ee.useEffect(()=>{if(e){o(!0),d("open");return}d("closed");const h=setTimeout(()=>o(!1),200);return()=>clearTimeout(h)},[e]),ee.useEffect(()=>{if(!s)return;const h=m=>{m.key==="Escape"&&t()};document.addEventListener("keydown",h);const f=document.body.style.overflow;return document.body.style.overflow="hidden",()=>{document.removeEventListener("keydown",h),document.body.style.overflow=f}},[s,t]),s?g.jsx("div",{"data-state":c,className:"dialog-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4",onClick:t,role:"dialog","aria-modal":"true","aria-label":"Upgrade your plan",children:g.jsxs("div",{"data-state":c,className:"dialog-panel relative w-full max-w-md rounded-2xl border border-[#222] bg-black p-6 shadow-lg sm:rounded-lg",onClick:h=>h.stopPropagation(),children:[g.jsx("button",{type:"button",onClick:t,"aria-label":"Close",className:"absolute right-4 top-4 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white",children:g.jsx(vp,{className:"h-4 w-4"})}),g.jsxs("div",{children:[g.jsx("h2",{className:"text-lg text-white",children:"Available in Strix Cloud"}),r&&g.jsx("p",{className:"mt-2 text-base leading-relaxed text-[#e5e5e5]",children:r})]}),g.jsxs("div",{className:"space-y-4 pt-4",children:[g.jsxs("div",{className:"rounded-xl border border-[#333] bg-[#0a0a0a] p-4 sm:rounded-lg",children:[g.jsxs("div",{className:"mb-3 flex items-center gap-2",children:[g.jsx(Um,{className:"h-4 w-4 text-blue-400"}),g.jsx("span",{className:"text-sm font-medium text-white",children:"Strix Cloud also includes"})]}),g.jsx("ul",{className:"space-y-2 text-sm text-[#888]",children:BU.map(h=>g.jsxs("li",{className:"flex items-center gap-2",children:[g.jsx(h.icon,{className:"h-3.5 w-3.5 text-[#555]"}),h.label]},h.label))})]}),g.jsxs("div",{className:"flex flex-col gap-2",children:[g.jsxs("a",{href:ha($u,"upgrade_try_free"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("upgrade_try_free",a),className:"flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-white px-4 text-sm font-semibold text-black transition-colors hover:bg-neutral-200",children:["Open Strix Cloud",g.jsx(ry,{className:"h-3.5 w-3.5"})]}),g.jsxs("a",{href:ha(TT,"upgrade_view_plans"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("upgrade_view_plans",a),className:"flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-[#333] px-4 text-sm font-medium text-[#888] transition-colors hover:border-[#555] hover:text-white",children:["Learn more",g.jsx(ry,{className:"h-3 w-3"})]})]})]})]})}):null}const Lm=160,zm=260,ro=400,HU=140,b_="strix_viewer_sidebar_width",x_="strix_viewer_sidebar_collapsed";function $U(e,t){try{const r=localStorage.getItem(e),a=r?parseInt(r,10):NaN;return Number.isFinite(a)?a:t}catch{return t}}function qU({view:e,onSelectView:t,issuesCount:r,agentCount:a,runCount:s,finished:o,verified:c,email:d,onOpenEmail:h,onOpenHistory:f,onForget:m}){var z;const[p,y]=ee.useState(()=>{const V=$U(b_,zm);return Math.min(ro,Math.max(Lm,V))}),[x,_]=ee.useState(()=>{try{return localStorage.getItem(x_)==="1"}catch{return!1}}),[N,S]=ee.useState(!1),[w,k]=ee.useState(!1),[E,M]=ee.useState(null),I=ee.useRef(null),R=(V,P)=>{jr(V,"sidebar"),M(P)},U=ee.useCallback(V=>{y(V);try{localStorage.setItem(b_,String(V))}catch{}},[]),B=ee.useCallback(V=>{_(V);try{localStorage.setItem(x_,V?"1":"0")}catch{}},[]),Z=ee.useCallback(()=>{B(!1),U(zm)},[B,U]),j=ee.useCallback(V=>{V.preventDefault(),S(!0)},[]);return ee.useEffect(()=>{if(!N||x)return;const V=T=>{const $=T.clientX;$>=Lm&&$<=ro?y($):$>ro&&y(ro)},P=T=>{const $=T.clientX;${window.removeEventListener("mousemove",V),window.removeEventListener("mouseup",P)}},[N,x,B,U]),ee.useEffect(()=>{if(!w)return;const V=P=>{I.current&&!I.current.contains(P.target)&&k(!1)};return document.addEventListener("mousedown",V),()=>document.removeEventListener("mousedown",V)},[w]),g.jsxs(g.Fragment,{children:[x&&g.jsx("div",{className:"fixed left-0 top-0 z-40 hidden h-full w-4 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.08)] lg:block",onClick:Z,title:"Expand sidebar"}),g.jsxs("aside",{className:Mr("sticky top-0 z-20 hidden h-screen flex-shrink-0 flex-col overflow-hidden border-r border-[rgba(255,255,255,0.08)] bg-black lg:flex",!N&&"transition-[width] duration-200 ease-out"),style:{width:x?0:p},children:[g.jsx("header",{className:"relative flex flex-col gap-1 pt-1 min-w-[160px]",children:g.jsx("div",{className:"flex flex-row py-1 px-2",children:g.jsxs("div",{className:"flex h-10 w-full flex-row items-center",children:[g.jsxs("a",{href:ha("https://app.strix.ai","logo"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("logo","sidebar"),className:"flex flex-1 flex-row items-center gap-2 rounded-md py-2 pl-2.5 pr-1 min-w-0 transition-colors hover:bg-[rgba(255,255,255,0.06)]",title:"Open Strix Cloud",children:[g.jsx("span",{className:"flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500",style:{width:20,height:20},children:g.jsx("span",{className:"text-[10px] font-semibold text-white",children:"S"})}),g.jsxs("span",{className:"flex flex-1 flex-row items-center gap-2 min-w-0",children:[g.jsx("span",{className:"truncate min-w-0 text-[14px] font-medium text-[#ededed]",children:"Strix"}),g.jsx("span",{className:"flex h-5 flex-shrink-0 items-center rounded px-2 text-[11px] font-medium text-[#888] bg-[rgba(255,255,255,0.08)]",children:"Local"})]})]}),g.jsx("a",{href:ha("https://app.strix.ai","logo"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("logo","sidebar"),className:"flex flex-none items-center rounded-md px-1.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]","aria-label":"Open Strix Cloud",children:g.jsx(Wk,{className:"h-4 w-4 text-[#666]"})})]})})}),g.jsx("nav",{className:"relative min-w-[160px] flex-1 overflow-y-auto overflow-x-clip scrollbar-thin pb-10 pt-2",children:g.jsxs("div",{className:"relative flex flex-col gap-px px-2",children:[g.jsx(yi,{icon:g.jsx(PU,{}),label:"Pentest Overview",active:e==="overview",onClick:()=>t("overview")}),g.jsx(yi,{icon:g.jsx(pT,{className:"h-4 w-4"}),label:"Issues",count:r>0?r:void 0,active:e==="issues",onClick:()=>t("issues")}),a>0&&g.jsx(yi,{icon:g.jsx(Ao,{className:"h-4 w-4"}),label:"Agents",count:a,active:e==="agents",onClick:()=>t("agents")}),g.jsx(yi,{icon:g.jsx(Vs,{className:"h-4 w-4"}),label:"Past runs",count:s>0?s:void 0,active:e==="history",onClick:f}),o&&g.jsx(yi,{icon:g.jsx(yp,{className:"h-4 w-4"}),label:"Export report",active:e==="email",onClick:h}),g.jsx(yi,{icon:g.jsx(pS,{className:"h-4 w-4"}),label:"Feedback & support",active:e==="feedback",onClick:()=>t("feedback")}),g.jsx("hr",{className:"mx-0 my-1 h-px w-full border-0 bg-[rgba(255,255,255,0.08)]"}),g.jsx(yi,{icon:g.jsx(zU,{className:"h-4 w-4"}),label:"PR Security Reviews",active:!1,onClick:()=>R("pr_reviews","Strix reviews every pull request and flags exploitable changes before they merge.")}),g.jsx(yi,{icon:g.jsx(IU,{className:"h-4 w-4"}),label:"Integrations",active:!1,onClick:()=>R("integrations","Sync findings to Jira, Linear, and Slack so fixes happen where your team already works.")}),g.jsx(yi,{icon:g.jsx(bT,{className:"h-4 w-4"}),label:"Members",active:!1,onClick:()=>R("members","Invite your team, set roles, and share findings and run history across your org.")})]})}),g.jsx("section",{className:"flex min-w-[160px] flex-col gap-0.5",ref:I,children:g.jsxs("div",{className:"relative p-2",children:[c&&d?g.jsxs("button",{onClick:()=>k(V=>!V),className:"relative flex w-full cursor-pointer items-center gap-2 rounded-md bg-transparent px-2.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]",children:[g.jsx("span",{className:"flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500",style:{width:20,height:20},children:g.jsx("span",{className:"text-[9px] font-semibold text-white",children:((z=d[0])==null?void 0:z.toUpperCase())||"U"})}),g.jsxs("span",{className:"flex min-w-0 flex-1 flex-col text-left",children:[g.jsx("span",{className:"truncate text-[13px] font-medium text-[#ededed]",children:d}),g.jsx("span",{className:"truncate text-[11px] text-[#555]",children:"Linked to this machine"})]})]}):g.jsxs("div",{className:"flex items-center gap-2 rounded-md px-2.5 py-2",children:[g.jsx("span",{className:"flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500",style:{width:20,height:20},children:g.jsx("span",{className:"text-[9px] font-semibold text-white",children:"S"})}),g.jsx("span",{className:"flex min-w-0 flex-1 flex-col text-left",children:g.jsx("span",{className:"truncate text-[13px] font-medium text-[#ededed]",children:"Local viewer"})})]}),w&&c&&d&&g.jsxs("div",{className:"absolute bottom-full left-2 right-2 z-50 mb-1 overflow-hidden rounded-lg border border-[#333] bg-black shadow-xl",children:[g.jsxs("div",{className:"border-b border-[#333] px-3 py-2",children:[g.jsx("p",{className:"truncate text-[13px] font-medium text-white",children:"Linked email"}),g.jsx("p",{className:"truncate text-[11px] text-[#666]",children:d})]}),g.jsxs("button",{onClick:()=>{k(!1),m()},className:"flex w-full items-center gap-2 px-3 py-2 text-[13px] text-[#888] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-red-400",children:[g.jsx(BC,{className:"h-4 w-4"}),"Forget this email"]})]})]})}),g.jsx("div",{className:"group absolute right-0 top-0 z-30 h-full w-1 cursor-col-resize",onMouseDown:j,children:g.jsx("div",{className:Mr("absolute right-0 top-0 h-full w-px bg-[rgba(255,255,255,0.08)] transition-all duration-100",N?"w-0.5 bg-[rgba(255,255,255,0.3)]":"group-hover:bg-[rgba(255,255,255,0.2)]")})})]}),N&&g.jsx("div",{className:"fixed inset-0 z-10 cursor-col-resize"}),g.jsx(UU,{open:E!==null,description:E??"",source:"sidebar",onClose:()=>M(null)})]})}function yi({icon:e,label:t,active:r,onClick:a,count:s}){return g.jsxs("button",{onClick:a,className:Mr("group flex h-9 w-full origin-left flex-row items-center rounded-md transition-colors",r?"bg-[rgba(255,255,255,0.12)] text-white":"text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-[#ededed]"),children:[g.jsx("div",{className:"grid flex-none place-content-center",style:{width:36,height:36},children:e}),g.jsx("span",{className:"min-w-0 flex-1 truncate text-left text-[14px] font-medium",children:t}),s!=null&&g.jsx("span",{className:"mr-2 flex-none rounded-full border border-white/10 px-2 py-0.5 text-[10px] tabular-nums leading-none text-[#777]",children:s})]})}function PU(){return g.jsx("svg",{style:{width:16,height:16,color:"currentcolor"},viewBox:"0 0 16 16",fill:"currentColor",children:g.jsx("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M2.5 5.5V2.5H5.5V5.5H2.5ZM1 2C1 1.44772 1.44772 1 2 1H6C6.55228 1 7 1.44772 7 2V6C7 6.55228 6.55228 7 6 7H2C1.44772 7 1 6.55228 1 6V2ZM2.5 13.5V10.5H5.5V13.5H2.5ZM1 10C1 9.44772 1.44772 9 2 9H6C6.55228 9 7 9.44772 7 10V14C7 14.5523 6.55228 15 6 15H2C1.44772 15 1 14.5523 1 14V10ZM10.5 2.5V5.5H13.5V2.5H10.5ZM10 1C9.44772 1 9 1.44772 9 2V6C9 6.55228 9.44772 7 10 7H14C14.5523 7 15 6.55228 15 6V2C15 1.44772 14.5523 1 14 1H10ZM10.5 13.5V10.5H13.5V13.5H10.5ZM9 10C9 9.44772 9.44772 9 10 9H14C14.5523 9 15 9.44772 15 10V14C15 14.5523 14.5523 15 14 15H10C9.44772 15 9 14.5523 9 14V10Z"})})}const y_={work_email_required:"Please use your work email, not a personal one.",rate_limited:"Too many requests. Wait a minute and try again.",invalid_email:"That email does not look right. Check it and try again.",unavailable:"The email service is unavailable right now. Try again shortly."},FU=new Set(["gmail.com","googlemail.com","yahoo.com","ymail.com","outlook.com","hotmail.com","live.com","icloud.com","me.com","aol.com","proton.me","protonmail.com","gmx.com","mail.com"]);function GU({onVerified:e}){const[t,r]=ee.useState("email"),[a,s]=ee.useState(""),[o,c]=ee.useState(""),[d,h]=ee.useState(!1),[f,m]=ee.useState(null),[p,y]=ee.useState(null),x=async()=>{const N=a.trim();if(!N){m("Enter your email to continue.");return}const S=N.slice(N.lastIndexOf("@")+1).toLowerCase();if(FU.has(S)){Tr("work_email_required"),m(y_.work_email_required);return}h(!0),m(null);const w=await uS(N);h(!1),w.ok?(Tr("email_submitted",{purpose:"verify"}),y(`We sent a 6-digit code to ${N}.`),r("code")):(w.error==="work_email_required"&&Tr("work_email_required"),m(y_[w.error]??"Could not send a code. Try again."))},_=async()=>{const N=o.trim();if(N.length<4){m("Enter the 6-digit code from your email.");return}h(!0),m(null);const S=await dS(a.trim(),N);if(h(!1),!S.verified){m("That code did not match. Check it and try again.");return}Tr("email_verified",{purpose:"verify"}),e()};return g.jsxs("div",{className:"mx-auto mt-5 max-w-sm text-left",children:[f&&g.jsxs("div",{className:"mb-3 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2",children:[g.jsx(Hu,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs text-red-300",children:f})]}),p&&!f&&g.jsx("p",{className:"mb-3 text-xs text-[#888]",children:p}),t==="email"?g.jsxs("form",{className:"space-y-3",onSubmit:N=>{N.preventDefault(),x()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your work email"}),g.jsx("input",{type:"email",autoFocus:!0,value:a,onChange:N=>s(N.target.value),placeholder:"you@company.com",className:"w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}}),g.jsx("span",{className:"mt-1.5 block text-[11px] text-[#666]",children:"Use your work email."})]}),g.jsxs("button",{type:"submit",disabled:d,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[d&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),"Send me a code"]})]}):g.jsxs("form",{className:"space-y-3",onSubmit:N=>{N.preventDefault(),_()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"6-digit code"}),g.jsx("input",{inputMode:"numeric",autoFocus:!0,value:o,onChange:N=>c(N.target.value.replace(/\D/g,"").slice(0,6)),placeholder:"123456",className:"w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}})]}),g.jsxs("button",{type:"submit",disabled:d,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[d&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),"Verify"]}),g.jsx("button",{type:"button",onClick:()=>{r("email"),m(null),y(null)},className:"w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]",children:"Use a different email"})]})]})}const VU=[{key:"critical",dot:"bg-red-500",text:"text-red-500"},{key:"high",dot:"bg-orange-500",text:"text-orange-500"},{key:"medium",dot:"bg-yellow-500",text:"text-yellow-500"},{key:"low",dot:"bg-blue-500",text:"text-blue-500"}];function YU({counts:e}){const t=VU.filter(r=>e[r.key]>0);return t.length===0?g.jsx("span",{className:"text-xs text-[#555]",children:"No findings"}):g.jsx("div",{className:"flex items-center gap-3",children:t.map(r=>g.jsxs("div",{className:"flex items-center gap-1.5",children:[g.jsx("span",{className:`h-2 w-2 rounded-full ${r.dot}`,"aria-hidden":"true"}),g.jsx("span",{className:`text-xs tabular-nums ${r.text}`,children:e[r.key]})]},r.key))})}function XU(e){if(!e)return null;const t=e.trim().replace(" UTC","Z").replace(" ","T"),r=new Date(t);return Number.isNaN(r.getTime())?null:r.toLocaleString(void 0,{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}function v_(e){if(!e)return null;const t=e.trim().replace(" UTC","Z").replace(" ","T"),r=new Date(t);if(Number.isNaN(r.getTime()))return null;const a=Date.now()-r.getTime(),s=Math.floor(a/6e4);if(s<1)return"just now";if(s<60)return`${s}m ago`;const o=Math.floor(s/60);if(o<24)return`${o}h ago`;const c=Math.floor(o/24);return c<7?`${c}d ago`:XU(e)}function KU({runs:e,activeRun:t,onSelectRun:r,onVerified:a}){const s=(e==null?void 0:e.count)??0,[o,c]=ee.useState(!1);return!e||e.locked?g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center",children:[g.jsx("div",{className:"mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-xl",style:{border:"1px solid #2a2a2a",background:"rgba(255,255,255,0.04)"},children:g.jsx(Vs,{className:"h-5 w-5 text-[#888]","aria-hidden":"true"})}),g.jsx("h2",{className:"text-base font-semibold text-white",children:"Browse every run on this machine"}),g.jsxs("p",{className:"mx-auto mt-1.5 max-w-md text-sm text-[#888]",children:["You have ",s," past ",s===1?"run":"runs"," on this machine."]}),o?g.jsxs(g.Fragment,{children:[g.jsx("p",{className:"mx-auto mt-3 max-w-sm text-xs text-[#666]",children:"Verify your email with a one-time code to unlock the full history."}),g.jsx(GU,{onVerified:a})]}):g.jsx("button",{onClick:()=>{jr("history_unlock","past_runs"),c(!0)},className:"mt-4 cursor-pointer rounded-lg bg-white px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90",children:"View runs"}),g.jsxs("p",{className:"mt-4 flex items-center justify-center gap-1.5 text-xs text-[#555]",children:[g.jsx(B_,{className:"h-3.5 w-3.5","aria-hidden":"true"}),"Or open one from the CLI with"," ",g.jsx("code",{className:"font-mono text-[#888]",children:"strix view "})]})]}):e.runs.length===0?g.jsx("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]",children:"No past runs found on this machine yet."}):g.jsx("div",{className:"space-y-2",children:e.runs.map(d=>{const h=d.name===t,f=v_(d.start_time)??v_(d.end_time),m=bo(d.target,d.name);return g.jsxs("button",{onClick:()=>r(d.name),className:`animate-card-in group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${h?"border-[#444] bg-[rgba(255,255,255,0.04)]":"border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"}`,children:[g.jsxs("div",{className:"min-w-0 flex-1",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"truncate text-sm font-medium text-white",children:m}),h&&g.jsx("span",{className:"rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400",style:{border:"1px solid rgba(16,185,129,0.3)"},children:"Active"})]}),g.jsxs("div",{className:"mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-[#666]",children:[d.scan_mode&&g.jsx("span",{className:"capitalize",children:d.scan_mode}),d.scan_mode&&(f||d.status)&&g.jsx("span",{className:"text-[#333]",children:"Β·"}),f&&g.jsx("span",{children:f}),f&&d.status&&g.jsx("span",{className:"text-[#333]",children:"Β·"}),d.status&&g.jsx("span",{className:"capitalize",children:d.status})]})]}),g.jsx(YU,{counts:d.severity_counts}),g.jsx(Kk,{className:"h-4 w-4 flex-shrink-0 text-[#555] transition-colors group-hover:text-[#aaa]","aria-hidden":"true"})]},d.name)})})}const __={work_email_required:"Please use your work email, not a personal one.",rate_limited:"Too many requests. Wait a minute and try again.",invalid_email:"That email does not look right. Check it and try again.",unavailable:"The email service is unavailable right now. Try again shortly."},ZU={forbidden:"This email was unsubscribed from Strix, so we cannot send to it.",too_large:"This report is too large to email. Try a smaller run.",unavailable:"The email service is unavailable right now. Try again shortly."},QU=new Set(["gmail.com","googlemail.com","yahoo.com","ymail.com","outlook.com","hotmail.com","live.com","icloud.com","me.com","aol.com","proton.me","protonmail.com","gmx.com","mail.com"]);function WU({activeRun:e,auth:t,purpose:r,skipDisclosure:a=!1,onAuthChanged:s,onExit:o}){const c=(t==null?void 0:t.verified)===!0,d=r==="verify",[h,f]=ee.useState(()=>d?"email":a?c?"sending":"email":"disclosure"),[m,p]=ee.useState((t==null?void 0:t.email)??""),[y,x]=ee.useState(""),[_,N]=ee.useState(!1),[S,w]=ee.useState(null),[k,E]=ee.useState(null),[M,I]=ee.useState(""),[R,U]=ee.useState(""),[B,Z]=ee.useState(!1),[j,z]=ee.useState(""),V=ee.useRef(!1),P=async()=>{f("sending"),w(null);const K=await SU(e);if(K.ok){Tr("report_sent"),I(K.password),U(K.filename),f("password");return}if(K.error==="reverify"||K.error==="unverified"){E("Your verification expired. Enter your email to verify again."),f("email");return}w(ZU[K.error]??"Could not send the report. Try again."),f("disclosure")},T=()=>{w(null),E(null),c?P():f("email")};ee.useEffect(()=>{!d&&a&&c&&!V.current&&(V.current=!0,P())},[]);const $=async()=>{const K=m.trim();if(!K){w("Enter your email to continue.");return}const C=K.slice(K.lastIndexOf("@")+1).toLowerCase();if(QU.has(C)){Tr("work_email_required"),w(__.work_email_required);return}N(!0),w(null);const D=await uS(K);N(!1),D.ok?(Tr("email_submitted",{purpose:r}),E(`We sent a 6-digit code to ${K}.`),f("code")):(D.error==="work_email_required"&&Tr("work_email_required"),w(__[D.error]??"Could not send a code. Try again."))},O=async()=>{const K=y.trim();if(K.length<4){w("Enter the 6-digit code from your email.");return}N(!0),w(null);const C=await dS(m.trim(),K);if(N(!1),!C.verified){w("That code did not match. Check it and try again.");return}Tr("email_verified",{purpose:r}),z(C.email),s(),d?o("history"):P()},H=async()=>{try{await navigator.clipboard.writeText(M),Z(!0),setTimeout(()=>Z(!1),1500)}catch{}},X=j||(t==null?void 0:t.email)||m.trim();return g.jsxs("div",{className:"mx-auto max-w-xl space-y-4",children:[g.jsxs("button",{onClick:()=>o(d?"history":"overview"),className:"cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white",children:[g.jsx(xp,{className:"h-4 w-4"}),d?"Back to past runs":"Back to results"]}),g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(yp,{className:"h-5 w-5 text-[#888]","aria-hidden":"true"}),g.jsx("h1",{className:"text-2xl font-semibold text-white",children:d?"Verify your email":"Export report to PDF"})]}),g.jsxs("div",{className:"w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6",style:{border:"1px solid #2a2a2a"},children:[g.jsx("p",{className:"mb-4 text-xs text-[#666]",children:d?"We send a one-time code to confirm it is you.":"Verified by a one-time code sent to your email"}),S&&g.jsxs("div",{className:"mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2",children:[g.jsx(Hu,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs text-red-300",children:S})]}),k&&!S&&h!=="password"&&g.jsx("p",{className:"mb-4 text-xs text-[#888]",children:k}),h==="disclosure"&&g.jsxs("div",{className:"space-y-4",children:[g.jsxs("div",{className:"space-y-2.5 rounded-lg p-3.5",style:{border:"1px solid #222",background:"rgba(255,255,255,0.02)"},children:[g.jsxs("div",{className:"flex items-start gap-2.5",children:[g.jsx(I_,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsxs("p",{className:"text-xs leading-relaxed text-[#aaa]",children:["We email an ",g.jsx("span",{className:"text-white",children:"encrypted PDF"}),". Nothing else leaves your machine."]})]}),g.jsxs("div",{className:"flex items-start gap-2.5",children:[g.jsx(zC,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]","aria-hidden":"true"}),g.jsx("p",{className:"text-xs leading-relaxed text-[#aaa]",children:"Only you hold the password; Strix can't read it."})]})]}),g.jsx("button",{onClick:T,className:"w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90",children:"Export report"}),c&&(t==null?void 0:t.email)&&g.jsxs("p",{className:"text-center text-xs text-[#666]",children:["Sending to ",t.email]})]}),h==="email"&&g.jsxs("form",{className:"space-y-4",onSubmit:K=>{K.preventDefault(),$()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your work email"}),g.jsx("input",{type:"email",autoFocus:!0,value:m,onChange:K=>p(K.target.value),placeholder:"you@company.com",className:"w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}})]}),g.jsxs("button",{type:"submit",disabled:_,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[_&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),"Send me a code"]})]}),h==="code"&&g.jsxs("form",{className:"space-y-4",onSubmit:K=>{K.preventDefault(),O()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"6-digit code"}),g.jsx("input",{inputMode:"numeric",autoFocus:!0,value:y,onChange:K=>x(K.target.value.replace(/\D/g,"").slice(0,6)),placeholder:"123456",className:"w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}})]}),g.jsxs("button",{type:"submit",disabled:_,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[_&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),d?"Verify":"Verify and send"]}),g.jsx("button",{type:"button",onClick:()=>{f("email"),w(null),E(null)},className:"w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]",children:"Use a different email"})]}),h==="sending"&&g.jsxs("div",{className:"flex flex-col items-center gap-3 py-8",children:[g.jsx(qs,{className:"h-6 w-6 animate-spin text-white","aria-hidden":"true"}),g.jsx("p",{className:"text-sm text-[#aaa]",children:"Generating and encrypting locally..."})]}),h==="password"&&g.jsxs("div",{className:"space-y-4",children:[g.jsxs("div",{className:"flex items-start gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5",children:[g.jsx(Gs,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsxs("p",{className:"text-xs text-emerald-200",children:["Sent to ",X,". Open the attached PDF with this password."]})]}),g.jsxs("div",{children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your one-time password"}),g.jsxs("div",{className:"flex items-center gap-2 rounded-lg bg-black p-3",style:{border:"1px solid #2a2a2a"},children:[g.jsx("code",{className:"flex-1 break-all font-mono text-base text-white",children:M}),g.jsxs("button",{onClick:H,className:"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs text-[#aaa] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white",style:{border:"1px solid #2a2a2a"},children:[B?g.jsx(Gs,{className:"h-3.5 w-3.5"}):g.jsx(mo,{className:"h-3.5 w-3.5"}),B?"Copied":"Copy"]})]}),g.jsxs("p",{className:"mt-2 text-xs text-[#666]",children:["Save this now. Strix never stores it, so we cannot show it again. File:"," ",g.jsx("span",{className:"font-mono text-[#888]",children:R})]})]}),g.jsx("button",{onClick:()=>o("overview"),className:"w-full cursor-pointer rounded-lg px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[rgba(255,255,255,0.06)]",style:{border:"1px solid #2a2a2a"},children:"Done"})]})]})]})}function la(e){return e&&typeof e=="object"&&!Array.isArray(e)?e:{}}function io(e){return Array.isArray(e)?e:[]}function nr(e){return typeof e=="string"&&e.trim()?e:null}function Ba(e){return typeof e=="number"&&Number.isFinite(e)?e:null}function JU(e){return e.replace(/_/g," ")}function w_(e){return e&&e.charAt(0).toUpperCase()+e.slice(1)}function eH(e){if(e==null||e<0)return"n/a";const t=Math.floor(e/3600),r=Math.floor(e%3600/60),a=e%60;return t?`${t}h ${r}m ${a}s`:r?`${r}m ${a}s`:`${a}s`}function wn({label:e,children:t}){return g.jsxs("div",{className:"grid grid-cols-[7rem_1fr] gap-3 items-baseline",children:[g.jsx("dt",{className:"text-[11px] uppercase tracking-wide text-[#666]",children:e}),g.jsx("dd",{className:"min-w-0 break-words text-sm text-[#ddd]",children:t})]})}function tH({raw:e,durationSeconds:t}){const[r,a]=ee.useState(!0),s=io(e.targets_info).map(P=>{const T=la(P),$=nr(T.original)??nr(la(T.details).target_url)??"unknown target",O=nr(T.type);return{display:$,type:O?JU(O):null}}),o=nr(e.instruction),c=w_(nr(e.scan_mode)),d=nr(e.scope_mode),h=la(e.diff_scope),f=h.active===!0,m=nr(h.mode),p=nr(e.diff_base),y=e.non_interactive===!0,x=io(e.local_sources).map(P=>{if(typeof P=="string")return P;const T=la(P);return nr(T.source_path)??nr(T.target_path)??""}).filter(Boolean),_=w_(nr(e.status));let N=d??"auto";f&&(N+=` (diff${m?`: ${m}`:""}${p?` vs ${p}`:""})`);const S=la(e.llm_usage),w=Object.keys(S).length>0,k=io(S.agents).map(la),E=Array.from(new Set(k.map(P=>nr(P.model)).filter(P=>!!P))),M=Ba(S.requests),I=Ba(S.input_tokens),R=Ba(la(io(S.input_tokens_details)[0]).cached_tokens),U=Ba(S.output_tokens),B=Ba(la(io(S.output_tokens_details)[0]).reasoning_tokens),Z=Ba(S.total_tokens),j=Ba(S.cost),z=nr(e.auth_mode)==="subscription",V=(P,T)=>g.jsxs("span",{className:"text-[#666]",children:[" (",Ds(P)," ",T,")"]});return g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsxs("button",{type:"button",onClick:()=>a(P=>!P),"aria-expanded":r,className:"flex w-full cursor-pointer items-center gap-2 text-left",children:[g.jsx(OC,{className:"h-4 w-4 text-[#888]","aria-hidden":"true"}),g.jsx("h2",{className:"text-sm font-semibold text-white",children:"Run details"}),r?g.jsx(O_,{className:"ml-auto h-4 w-4 text-[#666]","aria-hidden":"true"}):g.jsx(ho,{className:"ml-auto h-4 w-4 text-[#666]","aria-hidden":"true"})]}),r&&g.jsxs("div",{className:"mt-4 grid grid-cols-1 gap-x-8 gap-y-6 md:grid-cols-2",children:[g.jsxs("section",{children:[g.jsx("h3",{className:"mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]",children:"Configuration"}),g.jsxs("dl",{className:"space-y-2.5",children:[s.length>0&&g.jsx(wn,{label:"Targets",children:g.jsx("div",{className:"space-y-1",children:s.map((P,T)=>g.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[g.jsx("span",{className:"font-mono text-[#ddd]",children:P.display}),P.type&&g.jsx("span",{className:"rounded-full border border-[#2a2a2a] px-1.5 py-0.5 text-[10px] text-[#888]",children:P.type})]},T))})}),g.jsx(wn,{label:"Instruction",children:o?g.jsx("span",{className:"whitespace-pre-wrap",children:o}):g.jsx("span",{className:"text-[#666]",children:"None"})}),c&&g.jsx(wn,{label:"Pentest mode",children:c}),g.jsx(wn,{label:"Scope",children:N}),g.jsx(wn,{label:"Mode",children:y?"Non-interactive":"Interactive"}),x.length>0&&g.jsx(wn,{label:"Local sources",children:g.jsx("div",{className:"space-y-0.5 font-mono text-[#ddd]",children:x.map((P,T)=>g.jsx("div",{children:P},T))})}),_&&g.jsx(wn,{label:"Status",children:_})]})]}),g.jsxs("section",{children:[g.jsx("h3",{className:"mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]",children:"Usage & cost"}),w?g.jsxs("dl",{className:"space-y-2.5 tabular-nums",children:[g.jsx(wn,{label:"Model",children:E.length?E.join(", "):"n/a"}),z&&g.jsx(wn,{label:"Provider",children:g.jsx("span",{className:"inline-flex items-center gap-1.5",children:g.jsx("span",{className:"rounded-full border border-[#22c55e]/40 bg-[#22c55e]/10 px-2 py-0.5 text-[11px] text-[#22c55e]",children:"ChatGPT subscription"})})}),g.jsx(wn,{label:"Run time",children:eH(t)}),M!=null&&g.jsx(wn,{label:"Requests",children:Ds(M)}),I!=null&&g.jsxs(wn,{label:"Input tokens",children:[Ds(I),R!=null&&V(R,"cached")]}),U!=null&&g.jsxs(wn,{label:"Output tokens",children:[Ds(U),B!=null&&V(B,"reasoning")]}),Z!=null&&g.jsx(wn,{label:"Total tokens",children:Ds(Z)}),z?g.jsxs(wn,{label:"Cost",children:[g.jsx("span",{className:"text-[#22c55e]",children:"$0.00"}),g.jsx("span",{className:"text-[#666]",children:" (subscription)"})]}):j!=null&&g.jsxs(wn,{label:"Cost",children:["$",j.toFixed(2)]}),k.length>0&&g.jsx(wn,{label:"Agents",children:Ds(k.length)})]}):g.jsx("p",{className:"text-sm text-[#666]",children:"Not available yet."})]})]})]})}const E_="strix_viewer_trust_dismissed";function nH({message:e}){const[t,r]=ee.useState(()=>{try{return localStorage.getItem(E_)==="1"}catch{return!1}});if(t)return null;const a=()=>{try{localStorage.setItem(E_,"1")}catch{}r(!0)};return g.jsx("div",{className:"fixed bottom-3 left-3 z-[60] max-w-xs rounded-lg bg-[#0a0a0a] p-3 shadow-2xl",style:{border:"1px solid #2a2a2a"},role:"status",children:g.jsxs("div",{className:"flex gap-2.5",children:[g.jsx(I_,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs leading-relaxed text-[#aaa]",children:e}),g.jsx("button",{onClick:a,"aria-label":"Dismiss",className:"-mr-0.5 -mt-0.5 flex-shrink-0 cursor-pointer rounded p-0.5 text-[#666] transition-colors hover:text-white",children:g.jsx(vp,{className:"h-3.5 w-3.5"})})]})})}const rH=5e3,N_={invalid_email:"That email doesn't look right.",invalid_message:"Please write a little more.",unavailable:"Couldn't send that just now. Try again."};function iH({defaultEmail:e,onExit:t}){const[r,a]=ee.useState(""),[s,o]=ee.useState(e??""),[c,d]=ee.useState("form"),[h,f]=ee.useState(null),m=r.trim().length>0&&s.trim().length>0&&c!=="sending",p=async()=>{if(!m)return;d("sending"),f(null);const y=await wU(r.trim(),s.trim());if(y.ok){d("sent");return}d("form"),f(N_[y.error]??N_.unavailable)};return g.jsxs("div",{className:"mx-auto max-w-xl space-y-4",children:[g.jsxs("button",{onClick:()=>t("overview"),className:"cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white",children:[g.jsx(xp,{className:"h-4 w-4"}),"Back to results"]}),g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(pS,{className:"h-5 w-5 text-[#888]","aria-hidden":"true"}),g.jsx("h1",{className:"text-2xl font-semibold text-white",children:"Feedback & support"})]}),g.jsx("div",{className:"w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6",style:{border:"1px solid #2a2a2a"},children:c==="sent"?g.jsxs("div",{className:"flex items-start gap-3",children:[g.jsx(j_,{className:"mt-0.5 h-5 w-5 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsxs("div",{className:"min-w-0",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Thanks, we got it."}),g.jsx("p",{className:"mt-1 text-xs text-[#888]",children:"We read every message. If it needs a reply, we'll reach out to the email you gave."}),g.jsx("button",{onClick:()=>{a(""),d("form")},className:"mt-4 cursor-pointer text-xs text-[#888] transition-colors hover:text-white",children:"Send more feedback"})]})]}):g.jsxs(g.Fragment,{children:[g.jsx("p",{className:"mb-4 text-xs text-[#666]",children:"Bugs, feature requests, or anything else. Tell us what's on your mind."}),h&&g.jsxs("div",{className:"mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2",children:[g.jsx(Hu,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs text-red-300",children:h})]}),g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your feedback"}),g.jsx("textarea",{autoFocus:!0,value:r,maxLength:rH,onChange:y=>a(y.target.value),rows:5,placeholder:"What's working, what's not, what you'd love to see…",className:"w-full resize-y rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"})]}),g.jsxs("label",{className:"mt-4 block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your work email"}),g.jsx("input",{type:"email",value:s,onChange:y=>o(y.target.value),placeholder:"you@company.com",className:"w-full rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"})]}),g.jsx("button",{onClick:()=>void p(),disabled:!m,className:"mt-4 flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:c==="sending"?"Sending…":"Send feedback"})]})})]})}function aH({text:e,children:t,className:r=""}){const[a,s]=ee.useState(!1);return g.jsxs("span",{className:`relative inline-flex ${r}`,onMouseEnter:()=>s(!0),onMouseLeave:()=>s(!1),onFocus:()=>s(!0),onBlur:()=>s(!1),children:[t,a&&g.jsx("span",{role:"tooltip",className:"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-[240px] -translate-x-1/2 rounded-md px-2.5 py-1.5 text-xs text-[#ddd] shadow-lg",style:{border:"1px solid #2a2a2a",background:"#0a0a0a"},children:e})]})}function gS({label:e,desc:t,slug:r,icon:a,surface:s}){return g.jsx(aH,{text:t,children:g.jsxs("a",{href:ha($u,r),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr(r,s),className:"group inline-flex items-center gap-2 rounded-lg border border-[#222] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-sm text-[#aaa] transition-colors hover:border-[#444] hover:text-white",children:[g.jsx(a,{className:"h-4 w-4 text-[#888] transition-colors group-hover:text-white","aria-hidden":"true"}),g.jsx("span",{children:e})]})})}const sH="Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix.",S_=["critical","high","medium","low"],lH=500;function oH(){const[e,t]=ee.useState(null),[r,a]=ee.useState(null),[s,o]=ee.useState(null),[c,d]=ee.useState(null),[h,f]=ee.useState("overview"),[m,p]=ee.useState(null),[y,x]=ee.useState(null),[_,N]=ee.useState("report"),[S,w]=ee.useState(!1),[k,E]=ee.useState(!1),M=ee.useCallback(async()=>{try{p(await EU())}catch{}},[]),I=ee.useCallback(async()=>{try{x(await yU())}catch{}},[]);ee.useEffect(()=>{M(),I(),vU().then(C=>E(C.can_steer)).catch(()=>{})},[M,I]);const R=ee.useRef(!1);ee.useEffect(()=>{let C=!1,D;R.current=!1;const Y=()=>{D=setTimeout(L,lH)},L=async()=>{if(!C)try{const{summary:G,raw:q,finished:Q}=await lS(e);if(C)return;if(Q&&!R.current){R.current=!0;const te=await h_(e);C||a(te);return}const[J,W]=await Promise.all([cS(e).catch(()=>({agents:[],events:[]})),oS(G.runId,e).catch(()=>[])]);if(C)return;a(te=>({summary:G,raw:q,finished:Q,transcript:J,vulnerabilities:W,reportMarkdown:(te==null?void 0:te.reportMarkdown)??null})),Y()}catch(G){if(C)return;o(G instanceof Error?G.message:"Could not load run data."),Y()}};return(async()=>{try{const G=await h_(e);if(C)return;a(G),G.finished?R.current=!0:Y()}catch(G){if(C)return;o(G instanceof Error?G.message:"Could not load run data."),Y()}})(),()=>{C=!0,D&&clearTimeout(D)}},[e]);const U=ee.useMemo(()=>r?bU(r.vulnerabilities):null,[r]),B=(r==null?void 0:r.vulnerabilities.find(C=>C.id===c))??null,Z=(r==null?void 0:r.transcript.agents.length)??0,j=(m==null?void 0:m.verified)===!0,z=ee.useRef(!1);ee.useEffect(()=>{z.current=!1},[e]),ee.useEffect(()=>{z.current||!r||(r.finished?(z.current=!0,f("overview")):Z>0&&(z.current=!0,f("agents")))},[r,Z]);const V=ee.useCallback(C=>{z.current=!0,f(C)},[]),P=ee.useCallback(C=>{t(C),d(null),a(null),o(null),z.current=!1},[]),T=ee.useCallback((C,D)=>{jr("email_report",D),N("report"),w(C),V("email")},[V]),$=ee.useCallback(()=>T(!1,"sidebar"),[T]),O=ee.useCallback(()=>T(!0,"overview"),[T]),H=ee.useCallback(()=>{I(),V("history")},[I,V]),X=ee.useCallback(async()=>{await M(),await I()},[M,I]),K=ee.useCallback(async()=>{await NU(),await M(),await I()},[M,I]);return g.jsxs("div",{className:"min-h-screen bg-black text-white flex",children:[g.jsx(qU,{view:h,onSelectView:C=>{d(null),C==="history"?H():V(C)},issuesCount:(r==null?void 0:r.vulnerabilities.length)??0,agentCount:Z,runCount:(y==null?void 0:y.count)??0,finished:(r==null?void 0:r.finished)??!1,verified:j,email:(m==null?void 0:m.email)??null,onOpenEmail:$,onOpenHistory:H,onForget:()=>void K()}),g.jsxs("div",{className:"flex-1 min-w-0",children:[g.jsx("div",{className:"border-b border-[#222]",children:g.jsxs("div",{className:"max-w-[88rem] mx-auto px-3 sm:px-6 py-4 flex items-center gap-1.5",children:[g.jsxs("a",{href:ha("https://app.strix.ai","logo"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("logo","topbar"),className:"flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100 lg:hidden",title:"Open Strix Cloud",children:[g.jsx("img",{src:"./logo.png",alt:"Strix",className:"w-10 h-8 object-cover"}),g.jsx("div",{className:"text-base text-white font-medium tracking-tight",children:"Strix"})]}),r&&g.jsx(uH,{finished:r.finished}),g.jsxs("div",{className:"ml-auto flex items-center gap-3",children:[j&&y&&!y.locked&&y.runs.length>0&&g.jsx(cH,{runs:y,activeRun:e,launchedName:bo((r==null?void 0:r.summary.targets[0])??null,(r==null?void 0:r.summary.runName)??(r==null?void 0:r.summary.runId)??"Current run"),onSelect:P}),g.jsxs("a",{href:ha($u,"run_in_cloud"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("run_in_cloud","topbar"),className:"inline-flex items-center gap-1 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90",children:["Run in the cloud",g.jsx(T_,{className:"w-3 h-3","aria-hidden":"true"})]})]})]})}),g.jsxs("div",{className:"max-w-[88rem] mx-auto px-3 sm:px-6 py-8 sm:py-12 space-y-6",children:[s&&!r&&h!=="history"&&h!=="email"&&g.jsxs("div",{className:"rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5",children:[g.jsx(Hu,{className:"w-5 h-5 flex-shrink-0 mt-0.5 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-sm text-red-300",children:s})]}),g.jsx("div",{className:"animate-page-in space-y-6",children:h==="email"?g.jsx(WU,{activeRun:e,auth:m,purpose:_,skipDisclosure:S,onAuthChanged:()=>{M(),I()},onExit:C=>f(C==="history"?"history":"overview")}):h==="feedback"?g.jsx(iH,{defaultEmail:(m==null?void 0:m.email)??null,onExit:C=>f(C)}):h==="history"?g.jsxs("div",{className:"space-y-4",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(Vs,{className:"w-5 h-5 text-[#888]","aria-hidden":"true"}),g.jsx("h1",{className:"text-2xl font-semibold text-white",children:"Past runs"})]}),g.jsx(KU,{runs:y,activeRun:e,onSelectRun:P,onVerified:()=>void X()})]}):!r&&!s?g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-10 text-center",children:[g.jsx("div",{className:"w-6 h-6 mx-auto mb-3 rounded-full border-2 border-[#333] border-t-white animate-spin"}),g.jsx("p",{className:"text-sm text-[#888]",children:"Loading run data…"})]}):r&&U?g.jsxs(g.Fragment,{children:[g.jsx(fH,{summary:r.summary}),g.jsxs("div",{className:"flex gap-5 border-b border-[#2a2a2a] lg:hidden",children:[g.jsx(Bm,{active:h==="overview",onClick:()=>V("overview"),children:"Pentest Overview"}),g.jsxs(Bm,{active:h==="issues",onClick:()=>V("issues"),children:["Issues",r.vulnerabilities.length>0?` (${r.vulnerabilities.length})`:""]}),Z>0&&g.jsxs(Bm,{active:h==="agents",onClick:()=>V("agents"),children:["Agents (",Z,")"]})]}),h==="overview"?g.jsx(bH,{summary:r.summary,counts:U,total:r.vulnerabilities.length,reportMarkdown:r.reportMarkdown,raw:r.raw,finished:r.finished,onOpenEmail:O}):h==="agents"&&Z>0?g.jsx(xH,{run:r,canSteer:k}):B?g.jsxs("div",{className:"space-y-4",children:[g.jsxs("button",{onClick:()=>d(null),className:"cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] hover:text-white transition-colors",children:[g.jsx(xp,{className:"w-4 h-4"})," Back to all findings"]}),g.jsx(tD,{vulnerability:B})]}):g.jsx(hH,{vulnerabilities:r.vulnerabilities,finished:r.finished,onSelect:C=>d(C)})]}):null},`${e??"launched"}:${h}:${c??""}`)]})]}),g.jsx(nH,{message:sH})]})}function cH({runs:e,activeRun:t,launchedName:r,onSelect:a}){const[s,o]=ee.useState(!1),c=e.runs.find(h=>h.name===t),d=c?bo(c.target,c.name):r;return g.jsxs("div",{className:"relative",children:[g.jsxs("button",{onClick:()=>o(h=>!h),onBlur:()=>setTimeout(()=>o(!1),150),"aria-label":"Switch pentest",className:"flex items-center gap-2 rounded-lg border border-[#3a3a3a] bg-[rgba(255,255,255,0.05)] px-3 py-2 text-sm text-white transition-colors hover:border-[#555] hover:bg-[rgba(255,255,255,0.09)]",children:[g.jsx(Vs,{className:"h-4 w-4 flex-shrink-0 text-[#888]","aria-hidden":"true"}),g.jsx("span",{className:"flex-shrink-0 text-[#888]",children:"Pentest"}),g.jsx("span",{className:"max-w-[260px] truncate font-medium",children:d}),g.jsx(ho,{className:"h-4 w-4 flex-shrink-0 text-[#aaa]","aria-hidden":"true"})]}),s&&g.jsxs("div",{className:"absolute right-0 z-50 mt-2 max-h-96 w-96 overflow-y-auto rounded-xl py-1.5 shadow-2xl",style:{border:"1px solid #3a3a3a",background:"#0a0a0a"},children:[g.jsx("div",{className:"border-b border-[#222] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[#666]",children:"Switch pentest"}),e.runs.map(h=>{const f=h.name===t;return g.jsxs("button",{onMouseDown:()=>a(h.name),className:`flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors hover:bg-[rgba(255,255,255,0.06)] ${f?"bg-[rgba(255,255,255,0.04)] text-white":"text-[#aaa]"}`,children:[g.jsxs("span",{className:"min-w-0 flex-1",children:[g.jsx("span",{className:"block truncate font-medium",children:bo(h.target,h.name)}),h.target&&g.jsx("span",{className:"block truncate font-mono text-xs text-[#666]",children:h.target})]}),f&&g.jsx("span",{className:"h-2 w-2 flex-shrink-0 rounded-full bg-emerald-400"})]},h.name)})]})]})}function uH({finished:e}){return e?g.jsxs("span",{className:"ml-3 inline-flex items-center gap-1.5 text-xs text-[#888]",children:[g.jsx("span",{className:"w-1.5 h-1.5 rounded-full bg-[#555]"}),"Complete"]}):g.jsxs("span",{className:"ml-3 inline-flex items-center gap-1.5 text-xs text-emerald-400",children:[g.jsxs("span",{className:"relative flex h-1.5 w-1.5",children:[g.jsx("span",{className:"absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping"}),g.jsx("span",{className:"relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400"})]}),"Live"]})}function dH(e){if(e==null)return null;if(e<60)return`${e}s`;const t=Math.floor(e/60);return t<60?`${t}m`:`${Math.floor(t/60)}h ${t%60}m`}function fH({summary:e}){const t=dH(e.durationSeconds);return g.jsxs("div",{children:[g.jsx("h1",{className:"text-2xl font-semibold text-white",children:bo(e.targets[0]??null,e.runName??e.runId??"Pentest results")}),g.jsxs("div",{className:"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#888]",children:[e.targets.length>0&&g.jsx("span",{className:"font-mono text-[#aaa]",children:e.targets.join(", ")}),e.scanMode&&g.jsx(Im,{label:e.scanMode}),t&&g.jsx(Im,{label:t}),e.status&&g.jsx(Im,{label:e.status})]})]})}function Im({label:e}){return g.jsxs(g.Fragment,{children:[g.jsx("span",{className:"text-[#333]",children:"Β·"}),g.jsx("span",{className:"capitalize",children:e})]})}function hH({vulnerabilities:e,finished:t,onSelect:r}){const a=[...e].sort((s,o)=>S_.indexOf(s.severity)-S_.indexOf(o.severity));return a.length===0?g.jsxs("div",{className:"space-y-4",children:[g.jsx("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]",children:t?"No findings in this run.":"No findings yet. The pentest is still running…"}),t&&g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsx("p",{className:"text-sm font-medium text-white",children:"Stay ahead of new exposures"}),g.jsx("p",{className:"mt-0.5 mb-3 text-xs text-[#666]",children:"Attack surface monitoring catches new exposures for your org over time."}),g.jsx(gS,{label:"Attack surface monitoring",desc:"Continuous coverage for your whole org.",slug:"asm",surface:"empty_state",icon:XC})]})]}):g.jsx("div",{className:"space-y-2",children:a.map(s=>g.jsxs("button",{onClick:()=>r(s.id),className:"animate-card-in cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3",children:[g.jsx("span",{className:`w-2.5 h-2.5 rounded-full flex-shrink-0 ${_p(s.severity)}`,"aria-hidden":"true"}),g.jsxs("span",{className:"flex-1 min-w-0",children:[g.jsx("span",{className:"block text-sm font-medium text-white truncate",children:s.title}),s.target&&g.jsx("span",{className:"block text-xs text-[#666] font-mono truncate",children:s.target})]}),g.jsx("span",{className:`text-xs font-semibold px-2 py-0.5 rounded-full border capitalize ${U_[s.severity]}`,children:s.severity})]},s.id))})}function mH(e){return e.replace(/^\s*#{1,6}[ \t]+.*(?:\r?\n)+/,"").trimStart()}function pH(e){const t=[];let r=null;for(const a of e.split(` +`)}function z7({args:e,result:t}){const r=e.action??"",a=e.code??e.script??"",s=t;let o=null;s&&typeof s=="object"?o=typeof s.stdout=="string"?s.stdout:null:typeof s=="string"&&(o=s);const c=r==="new_session"?"new session":r==="close"?"close session":r==="list_sessions"?"list sessions":null,d=o?L7(D7(o)):null;return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-yellow-400/80 font-semibold text-sm",children:"Python"}),c&&g.jsx("span",{className:"text-[#888] text-[13px]",children:c})]}),a&&g.jsx(dg,{code:a,language:"python",collapsible:!0}),d&&g.jsx(wi,{className:"text-[#666]",children:d})]})}function I7({args:e}){const t=e.targets,a=(Array.isArray(t)?t:[]).map(s=>typeof s=="object"&&s?s.original??null:null).filter(Boolean);return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Starting penetration test"}),a.length===1&&g.jsxs("span",{className:"text-[#888] text-[13px]",children:["on ",a[0]]})]}),a.length>1&&g.jsx("div",{className:"mt-1.5 space-y-0.5",children:a.map((s,o)=>g.jsxs("div",{className:"text-[13px] text-[#888]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"β€’"}),s]},o))})]})}function B7({args:e}){const t=e.name??"Unknown Agent",r=e.task??"";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-[#888] text-[13px]",children:"subagent"}),g.jsx("span",{className:"text-purple-400 font-semibold text-sm",children:t})]}),r&&g.jsx("div",{className:"mt-1.5",children:g.jsx(An,{text:r,maxLines:15})})]})}function U7(e){return e.toolName==="subagent_start_info"?g.jsx(B7,{...e}):g.jsx(I7,{...e})}function H7({args:e}){const t=e.executive_summary??"",r=e.methodology??"",a=e.technical_analysis??"",s=e.recommendations??"";return g.jsxs("div",{className:"space-y-3",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Penetration test completed"}),t&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Executive Summary"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:t,maxLines:25})})]}),r&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Methodology"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:r,maxLines:25})})]}),a&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Technical Analysis"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:a,maxLines:25})})]}),s&&g.jsxs("div",{children:[g.jsx("span",{className:"text-emerald-400/60 text-sm font-semibold",children:"Recommendations"}),g.jsx("div",{className:"mt-1",children:g.jsx(An,{text:s,maxLines:25})})]}),!t&&!r&&!a&&!s&&g.jsx("div",{className:"text-[#555] text-xs",children:"Generating final report..."})]})}function $7({toolName:e,args:t,result:r}){if(e==="create_note"){const a=t.title??"",s=t.content??"",o=t.category??"general";return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note"}),g.jsxs("span",{className:"text-[#555] text-[13px]",children:["(",o,")"]})]}),a&&g.jsx("div",{className:"mt-1.5 text-[#999] text-[13px]",children:a}),s&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:s})})]})}if(e==="delete_note")return g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note removed"});if(e==="update_note"){const a=t.title??"",s=t.content??"";return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note updated"}),a&&g.jsx("div",{className:"mt-1.5 text-[#999] text-[13px]",children:a}),s&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:s})})]})}if(e==="get_note"){const a=r,s=a&&typeof a=="object"&&a.success?a.note:void 0;return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note read"}),s&&g.jsxs(g.Fragment,{children:[g.jsxs("div",{className:"mt-1.5 text-[#999] text-[13px]",children:[s.title??"(untitled)",g.jsxs("span",{className:"text-[#555] ml-1",children:["(",s.category??"general",")"]}),(s.by_you||s.agent_name)&&g.jsxs("span",{className:"text-[#666] ml-1 text-xs",children:["by ",s.by_you?"you":s.agent_name]})]}),s.content&&g.jsx("div",{className:"mt-1",children:g.jsx(ua,{text:s.content})})]})]})}if(e==="list_notes"){const a=r;let s=[];if(a&&typeof a=="object"&&a.success){const o=a.notes;s=Array.isArray(o)?o:[]}return g.jsxs("div",{children:[g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"notes"}),s.length>0?g.jsx("div",{className:"mt-1.5 space-y-0.5",children:s.map((o,c)=>g.jsxs("div",{className:"text-[13px]",children:[g.jsx("span",{className:"text-[#555] mr-1",children:"-"}),g.jsx("span",{className:"text-[#999]",children:o.title??"(untitled)"}),g.jsxs("span",{className:"text-[#555] ml-1",children:["(",o.category??"general",")"]}),(o.by_you||o.agent_name)&&g.jsxs("span",{className:"text-[#666] ml-1 text-xs",children:["by ",o.by_you?"you":o.agent_name]}),o.content&&g.jsx("div",{className:"ml-3",children:g.jsx(ua,{text:o.content})})]},c))}):g.jsx("div",{className:"mt-1 text-[#555] text-xs",children:"No notes"})]})}return g.jsx("span",{className:"text-amber-400/80 font-semibold text-sm",children:"note"})}const q7={create_todo:{label:"Task added",Icon:VC},list_todos:{label:"Plan",Icon:Gk},update_todo:{label:"Task updated",Icon:qC},mark_todo_done:{label:"Task completed",Icon:R_},mark_todo_pending:{label:"Task reopened",Icon:eT},delete_todo:{label:"Task removed",Icon:hT}};function P7({status:e}){return e==="done"?g.jsx(R_,{className:"w-3.5 h-3.5 text-emerald-400/70 shrink-0"}):e==="in_progress"?g.jsx(rC,{className:"w-3.5 h-3.5 text-purple-400/70 shrink-0 animate-pulse"}):g.jsx(aC,{className:"w-3.5 h-3.5 text-[#444] shrink-0"})}function F7({todos:e,highlightId:t}){return g.jsx("div",{className:"space-y-0",children:e.map((r,a)=>{const s=r.status??"pending",o=t&&r.id===t;return g.jsxs("div",{className:`flex items-start gap-2.5 py-1.5 px-2 -mx-2 rounded-md transition-colors ${o?"bg-purple-500/[0.08]":""}`,children:[g.jsx("div",{className:"mt-[1px]",children:g.jsx(P7,{status:s})}),g.jsx("span",{className:`text-[13px] leading-snug ${s==="done"?"text-[#555] line-through":s==="in_progress"?"text-[#bbb]":"text-[#999]"}`,children:r.title??"(untitled)"})]},r.id??a)})})}function G7({toolName:e,args:t,result:r}){const a=q7[e]??{label:"Plan",Icon:ZC},s=a.Icon,o=r;if(typeof o=="string"&&o.trim())return g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(s,{className:"w-3.5 h-3.5 text-purple-400/60"}),g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:a.label})]}),g.jsx("div",{className:"mt-1.5 text-[#888] text-[13px]",children:o.trim()})]});let c=[],d=null,h;if(o&&typeof o=="object"){if(d=o.error??null,o.success){const m=o.todos;c=Array.isArray(m)?m:[]}h=o.id??t.todo_id??void 0}const f=e!=="list_todos"?h:void 0;return c.length===0&&!d?g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(s,{className:"w-3.5 h-3.5 text-purple-400/60"}),g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:a.label})]}):g.jsxs("div",{children:[g.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[g.jsx(s,{className:"w-3.5 h-3.5 text-purple-400/60"}),g.jsx("span",{className:"text-purple-400/80 font-semibold text-sm",children:a.label})]}),d&&g.jsx("div",{className:"text-red-400/70 text-[13px] mb-2",children:d}),c.length>0&&g.jsx("div",{className:"rounded-lg border border-white/[0.06] bg-white/[0.015] px-3 py-2",children:g.jsx(F7,{todos:c,highlightId:f})})]})}function d_(e){if(e==null)return null;if(typeof e=="string")return e.trim()?e:null;if(typeof e=="object"){const t=e;if(typeof t.__raw=="string")return t.__raw;if(Object.keys(t).length===0)return null;try{return JSON.stringify(e,null,2)}catch{return String(e)}}return String(e)}function nS({toolName:e,args:t,result:r}){const a=d_(t),s=d_(r);return g.jsxs("div",{children:[g.jsx("span",{className:"text-[#888] font-semibold text-sm",children:e.replace(/_/g," ")}),a&&g.jsx(wi,{className:"text-[#777]",children:a}),s&&g.jsx(wi,{className:"text-[#666]",children:s})]})}function V7({args:e}){const t=e.skills,r=(Array.isArray(t)?t:String(t??"").split(",")).map(a=>String(a).trim()).filter(Boolean);return g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-emerald-400/80 font-semibold text-sm",children:"Loading skill"}),r.length>0&&g.jsx("span",{className:"text-[#888] text-[13px]",children:r.join(", ")})]})}function Y7({args:e}){const t=e.message??"";return t?g.jsxs("div",{children:[g.jsx(ua,{text:t}),g.jsx("div",{className:"mt-1.5 text-[#888] text-[13px]",children:"waiting for your reply"})]}):null}const ad={terminal:{renderer:e7,icon:B_,color:"text-emerald-400"},python:{renderer:z7,icon:oC,color:"text-yellow-400"},browser:{renderer:n7,icon:z_,color:"text-blue-400"},filesystem:{renderer:r7,icon:gC,color:"text-sky-400"},proxy:{renderer:k7,icon:T_,color:"text-purple-400",match:/request|sitemap|scope/},reporting:{renderer:p7,icon:iT,color:"text-red-400"},thinking:{renderer:C7,icon:M_,color:"text-purple-400"},agents:{renderer:T7,icon:Ao,color:"text-cyan-400",match:/agent/},search:{renderer:A7,icon:nT,color:"text-amber-400"},lifecycle:{renderer:U7,icon:L_,color:"text-emerald-400"},notes:{renderer:$7,icon:uT,color:"text-amber-400",match:/note/},skills:{renderer:V7,icon:Hm,color:"text-emerald-400"},todos:{renderer:G7,icon:jC,color:"text-purple-400",match:/todo/},telemetry:{renderer:nS,icon:Hm,color:"text-[#555]"}},X7={terminal:["exec_command","write_stdin","terminal_execute"],python:["python_action"],browser:["browser_action"],filesystem:["apply_patch","view_image","str_replace_editor","list_files","search_files"],proxy:["list_requests","view_request","repeat_request","list_sitemap","view_sitemap_entry","scope_rules","send_request"],reporting:["create_vulnerability_report","list_reports","get_report"],thinking:["think"],agents:["create_agent","agent_finish","send_message_to_agent","wait_for_agents","view_agent_graph","stop_agent"],search:["web_search"],lifecycle:["scan_start_info","subagent_start_info","finish_scan","respond_to_user"],notes:["create_note","delete_note","update_note","list_notes","get_note"],skills:["load_skill"],todos:["create_todo","list_todos","update_todo","mark_todo_done","mark_todo_pending","delete_todo"],telemetry:["sandbox_error_details","llm_error_details"]},K7=Object.fromEntries(Object.entries(X7).flatMap(([e,t])=>t.map(r=>[r,e]))),Z7={finish_scan:H7,respond_to_user:Y7,apply_patch:u7,view_image:h7,list_reports:l_,get_report:l_},Q7={agent_finish:{icon:L_,color:"text-cyan-400"},send_message_to_agent:{icon:mh,color:"text-cyan-400"},wait_for_agents:{icon:mh,color:"text-cyan-400"},respond_to_user:{icon:mh,color:"text-emerald-400"},view_agent_graph:{icon:mC,color:"text-cyan-400"},stop_agent:{icon:A_,color:"text-red-400"},scan_start_info:{icon:dC,color:"text-emerald-400"},subagent_start_info:{icon:Ao,color:"text-purple-400"},view_image:{icon:AC,color:"text-sky-400"}},W7=ad.telemetry;function rS(e){var r;const t=K7[e];if(t)return t;for(const[a,s]of Object.entries(ad))if((r=s.match)!=null&&r.test(e))return a;return null}function J7(e){const t=Z7[e];if(t)return t;const r=rS(e);return r?ad[r].renderer:nS}function eU(e){const t=Q7[e];if(t)return t;const r=rS(e),a=r?ad[r]:W7;return{icon:a.icon,color:a.color}}const tU=30;function nU({role:e,content:t}){const r=e==="user"||e==="human";return g.jsxs("div",{children:[g.jsx("span",{className:`font-semibold text-sm ${r?"text-blue-400/80":"text-purple-400/80"}`,children:r?"User":"Thinking"}),g.jsx("div",{className:"mt-1.5 italic text-[#888]",children:g.jsx(An,{text:t,maxLines:tU})})]})}class rU extends ee.Component{constructor(t){super(t),this.state={hasError:!1}}static getDerivedStateFromError(){return{hasError:!0}}render(){return this.state.hasError?g.jsx("span",{className:"text-[#555] font-semibold text-sm",children:this.props.toolName.replace(/_/g," ")}):this.props.children}}function iU(e){const t=J7(e.toolName);return g.jsx(rU,{toolName:e.toolName,children:g.jsx(t,{...e})})}function iS(e){if(e==null||typeof e!="string")return e;const t=e.trim();if(!t)return e;try{return JSON.parse(t)}catch{}try{const r=t.replace(/\bNone\b/g,"null").replace(/\bTrue\b/g,"true").replace(/\bFalse\b/g,"false").replace(/'/g,'"');return JSON.parse(r)}catch{return{__raw:e}}}function aS(e){const t=iS(e);return t&&typeof t=="object"&&!Array.isArray(t)?t:t==null?{}:{__raw:typeof t=="string"?t:JSON.stringify(t)}}function f_(e){const t=/(\d+)$/.exec(e);return t?parseInt(t[1],10):0}function mg(e){var r;const t=(r=e.data)==null?void 0:r.role;return e.type==="chat"&&(t==="user"||t==="human")}function aU(e){var t;return mg(e)&&String(((t=e.data)==null?void 0:t.content)??"").startsWith("[Message from ")}function sU(e){const t=new Set;let r=!1;for(const a of e)if(mg(a)){if(aU(a)){t.add(a.id);continue}r||(r=!0,t.add(a.id))}return t}const lU={completed:"text-emerald-400 border-emerald-500/30 bg-emerald-500/10",running:"text-blue-400 border-blue-500/30 bg-blue-500/10",waiting:"text-yellow-400 border-yellow-500/30 bg-yellow-500/10",stopped:"text-[#aaa] border-[#333] bg-[#1a1a1a]",crashed:"text-red-400 border-red-500/30 bg-red-500/10",failed:"text-red-400 border-red-500/30 bg-red-500/10"};function oU(e){return e==="completed"?"completed":e==="running"?"running":e==="failed"||e==="crashed"?"failed":e}function cU(e,t){var d;const r=new Map;for(const h of e)if(h.parent_id){const f=r.get(h.parent_id)??[];f.push(h.id),r.set(h.parent_id,f)}const a=new Map,s=new Map,o=new Map;for(const h of t)if(h.type==="tool"){if(a.set(h.agent_id,(a.get(h.agent_id)??0)+1),((d=h.data)==null?void 0:d.tool_name)==="create_agent"){const f=aS(h.data.args),m=f.name??f.agent_name??"",p=f.task??"";m&&p&&o.set(m,p)}}else mg(h)||s.set(h.agent_id,(s.get(h.agent_id)??0)+1);const c=new Map;for(const h of e)c.set(h.id,{id:h.id,name:h.name,task:o.get(h.name)??"",status:oU(h.status),parentId:h.parent_id,children:r.get(h.id)??[],createdAt:h.created_at,toolCount:a.get(h.id)??0,messageCount:s.get(h.id)??0});return c}function uU({agent:e,events:t,showHeader:r=!0}){const a=ee.useMemo(()=>{const c=t.filter(h=>h.agent_id===e.id).sort((h,f)=>f_(h.id)-f_(f.id)),d=sU(c);return c.filter(h=>!d.has(h.id))},[t,e.id]),s=a.filter(c=>c.type==="tool").length,o=a.length-s;return g.jsxs("div",{children:[r&&g.jsxs(g.Fragment,{children:[g.jsxs("div",{className:"flex items-center gap-2 flex-wrap mb-1",children:[g.jsx("span",{className:"text-base font-semibold text-white truncate",children:e.name}),g.jsx("span",{className:`flex-shrink-0 text-xs font-medium capitalize px-2 py-0.5 rounded-full border ${lU[e.status]??"text-[#aaa] border-[#333] bg-[#1a1a1a]"}`,children:e.status}),g.jsx("span",{className:"font-mono text-xs text-[#555]",children:e.id})]}),g.jsxs("p",{className:"text-xs text-[#666] mb-4",children:[o," message",o===1?"":"s"," Β· ",s," tool call",s===1?"":"s"]})]}),a.length===0?g.jsx("p",{className:"text-sm text-[#666]",children:"No recorded activity for this agent."}):g.jsx("div",{className:"py-1",children:a.map((c,d)=>{var N,S,w,k,E,M;const h=d===a.length-1,f=c.type==="tool",m=f?String(((N=c.data)==null?void 0:N.tool_name)??"tool"):"",p=f?"":String(((S=c.data)==null?void 0:S.role)??"assistant");let y,x;if(f){const I=eU(m);y=I.icon,x=I.color}else{const I=p==="user"||p==="human";y=I?Ao:M_,x=I?"text-blue-400":"text-purple-400"}const _=f?String(((w=c.data)==null?void 0:w.status)??"completed"):"completed";return g.jsxs("div",{className:"flex gap-3",children:[g.jsxs("div",{className:"flex flex-col items-center shrink-0",children:[g.jsx("div",{className:`w-[30px] h-[30px] rounded-full bg-black border flex items-center justify-center shrink-0 ${f&&_==="running"?"border-blue-500/40 animate-pulse":f&&_==="failed"?"border-red-500/30":"border-[#222]"}`,children:g.jsx(y,{className:`w-3.5 h-3.5 ${x}`})}),!h&&g.jsx("div",{className:"w-px flex-1 bg-[#1a1a1a] mt-1"})]}),g.jsx("div",{className:"flex-1 min-w-0 pt-[5px] pb-6",children:f?g.jsx(iU,{toolName:m,args:aS((k=c.data)==null?void 0:k.args),result:iS((E=c.data)==null?void 0:E.result)??null,status:_}):g.jsx(nU,{role:p,content:String(((M=c.data)==null?void 0:M.content)??"")})})]},c.id)})})]})}class Iu extends Error{constructor(t){super(t),this.name="RunParseError"}}const dU=["critical","high","medium","low"];function fU(e){const t=String(e??"").toLowerCase().trim();return dU.includes(t)?t:"low"}function hU(e){if(typeof e=="string"&&e.trim()){const t=e.trim().replace(" UTC","Z").replace(" ","T"),r=new Date(t);if(!Number.isNaN(r.getTime()))return r.toISOString();const a=new Date(e);if(!Number.isNaN(a.getTime()))return a.toISOString()}return new Date().toISOString()}function Ot(e){return typeof e=="string"&&e.length>0?e:null}function mU(e){return typeof e=="number"&&Number.isFinite(e)?e:null}function sS(e,t){try{return JSON.parse(e)}catch{throw new Iu(`${t} isn't valid JSON. Make sure you selected a Strix run directory.`)}}function pU(e){const t=sS(e,"run.json");if(!t||typeof t!="object"||Array.isArray(t))throw new Iu("run.json is not an object.");const r=t,a=[],s=r.targets_info;if(Array.isArray(s)){for(const x of s)if(x&&typeof x=="object"){const _=x.original;typeof _=="string"&&_&&a.push(_)}}const o=Ot(r.start_time),c=Ot(r.end_time);let d=null;if(o&&c){const x=new Date(o).getTime(),_=new Date(c).getTime();!Number.isNaN(x)&&!Number.isNaN(_)&&_>=x&&(d=Math.round((_-x)/1e3))}let h=null,f=null,m=null,p=null;const y=r.scan_results;if(y&&typeof y=="object"){const x=y;h=Ot(x.executive_summary),f=Ot(x.technical_analysis),m=Ot(x.methodology),p=Ot(x.recommendations)}return{runId:Ot(r.run_id),runName:Ot(r.run_name),targets:a,scanMode:Ot(r.scan_mode),status:Ot(r.status),startTime:o,endTime:c,durationSeconds:d,executiveSummary:h,technicalAnalysis:f,methodology:m,recommendations:p}}function gU(){return{pr_review_id:null,cve:null,cvss:null,potential_risk_saving:null,risk_saving_description:null,impact:null,endpoint:null,method:null,target:null,technical_analysis:null,poc_description:null,poc_script_code:null,code_diff:null,code_file:null,code_before:null,code_after:null,cwe:null,code_locations:null,remediation_steps:null,fix_pr_body:null,evidence:null,assumptions:null,fix_effort:null,cvss_breakdown:null,status_changed_at:null,status_changed_by:null,status_note:null,snoozed_until:null,reopened_at:null,reopened_by:null,original_severity:null,severity_changed_at:null,severity_changed_by:null,severity_override_reason:null,retest_of_vulnerability_id:null}}function bU(e,t,r){const a=e.cwe,s=typeof a=="string"&&a.trim()?[a.trim()]:Array.isArray(a)?a.filter(c=>typeof c=="string"&&c):null;return{...gU(),id:Ot(e.id)??`vuln-${t+1}`,scan_id:r,title:Ot(e.title)??"Untitled finding",description:Ot(e.description)??"",severity:fU(e.severity),status:"open",created_at:hU(e.timestamp),cve:Ot(e.cve),cvss:mU(e.cvss),impact:Ot(e.impact),endpoint:Ot(e.endpoint),method:Ot(e.method),target:Ot(e.target),technical_analysis:Ot(e.technical_analysis),poc_description:Ot(e.poc_description),poc_script_code:Ot(e.poc_script_code),cwe:s,code_locations:Array.isArray(e.code_locations)?e.code_locations:null,remediation_steps:Ot(e.remediation_steps),fix_pr_body:Ot(e.fix_pr_body),evidence:Ot(e.evidence),assumptions:Ot(e.assumptions),fix_effort:Ot(e.fix_effort)??null,cvss_breakdown:e.cvss_breakdown??null}}function xU(e,t=null){const r=sS(e,"vulnerabilities.json");if(!Array.isArray(r))throw new Iu("vulnerabilities.json is not a JSON array.");return r.map((a,s)=>{if(!a||typeof a!="object")throw new Iu(`vulnerabilities.json entry #${s+1} is not an object.`);return bU(a,s,t)})}function yU(e){const t={critical:0,high:0,medium:0,low:0};for(const r of e)t[r.severity]+=1;return t}async function Ja(e){const t=await fetch(e,{cache:"no-store"});if(!t.ok)throw new Error(`${e} responded ${t.status}`);return t.json()}function sd(e){return e?`?run=${encodeURIComponent(e)}`:""}async function lS(e){const t=await Ja("/api/run"+sd(e)),r=pU(JSON.stringify(t)),a=t.finished===!0;return{summary:r,raw:t,finished:a}}async function oS(e,t){const r=await Ja("/api/vulnerabilities"+sd(t));return xU(JSON.stringify(r),e)}async function vU(e){const t=await Ja("/api/report"+sd(e));return(t==null?void 0:t.markdown)??null}async function cS(e){const t=await Ja("/api/transcript"+sd(e));return{agents:Array.isArray(t==null?void 0:t.agents)?t.agents:[],events:Array.isArray(t==null?void 0:t.events)?t.events:[]}}async function h_(e){const{summary:t,raw:r,finished:a}=await lS(e),[s,o,c]=await Promise.all([oS(t.runId,e).catch(()=>[]),vU(e).catch(()=>null),cS(e).catch(()=>({agents:[],events:[]}))]);return{summary:t,raw:r,finished:a,vulnerabilities:s,reportMarkdown:o,transcript:c}}async function rl(e,t){const r=await fetch(e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),cache:"no-store"});let a={};try{const s=await r.json();s&&typeof s=="object"&&(a=s)}catch{}return{ok:r.ok,status:r.status,data:a}}async function _U(){const e=await Ja("/api/runs");return{locked:(e==null?void 0:e.locked)??!0,count:typeof(e==null?void 0:e.count)=="number"?e.count:0,runs:Array.isArray(e==null?void 0:e.runs)?e.runs:[]}}async function wU(){const e=await Ja("/api/capabilities");return{can_steer:(e==null?void 0:e.can_steer)===!0}}async function EU(e,t){const{ok:r,data:a}=await rl("/api/agents/steer",{agent_id:e,message:t});return r&&a.ok===!0?{ok:!0}:{ok:!1,error:String(a.error??"unavailable")}}async function NU(e,t){const{ok:r,data:a}=await rl("/api/feedback",{message:e,email:t});return r&&a.ok===!0?{ok:!0}:{ok:!1,error:String(a.error??"unavailable")}}async function SU(){const e=await Ja("/api/auth/status");return{verified:(e==null?void 0:e.verified)===!0,email:(e==null?void 0:e.email)??null}}async function uS(e){const{ok:t,data:r}=await rl("/api/auth/otp/start",{email:e});return t&&r.ok===!0?{ok:!0}:{ok:!1,error:String(r.error??"unavailable")}}async function dS(e,t){const{ok:r,data:a}=await rl("/api/auth/otp/verify",{email:e,code:t});return r&&a.verified===!0?{verified:!0,email:String(a.email??e)}:{verified:!1,error:String(a.error??"invalid_code")}}async function kU(){await rl("/api/auth/forget",{})}async function CU(e){const{ok:t,data:r}=await rl("/api/report/send",e?{run:e}:{});return t&&r.ok===!0?{ok:!0,password:String(r.password??""),filename:String(r.filename??"strix-report.pdf")}:{ok:!1,error:String(r.error??"unavailable")}}const Bs="__root__";function fS({agents:e,fixedAgentId:t,className:r}){const a=ee.useRef(null),[s,o]=ee.useState(!1),[c,d]=ee.useState(!1),[h,f]=ee.useState(""),[m,p]=ee.useState(!1),[y,x]=ee.useState(null),_=t!=null,N=ee.useMemo(()=>e.find(z=>!z.parent_id)??e[0]??null,[e]),S=ee.useMemo(()=>e.filter(z=>z.parent_id&&z.status==="running"),[e]),[w,k]=ee.useState(Bs),[E,M]=ee.useState(!1);ee.useEffect(()=>{w!==Bs&&!S.some(z=>z.id===w)&&k(Bs)},[w,S]);const{targetId:I,targetName:R}=ee.useMemo(()=>{if(_){const V=e.find(P=>P.id===t)??null;return{targetId:t??null,targetName:(V==null?void 0:V.name)??"this agent"}}if(w===Bs)return{targetId:(N==null?void 0:N.id)??null,targetName:"Root agent"};const z=e.find(V=>V.id===w)??null;return{targetId:(z==null?void 0:z.id)??(N==null?void 0:N.id)??null,targetName:(z==null?void 0:z.name)??"Root agent"}},[e,t,_,N,w]),U=h.trim().length===0;ee.useLayoutEffect(()=>{const z=a.current;z&&(z.style.height="auto",z.style.height=`${z.scrollHeight}px`)},[h]);const B=ee.useCallback(()=>{o(!0),requestAnimationFrame(()=>{var z;return(z=a.current)==null?void 0:z.focus()})},[]),Z=ee.useCallback(()=>{o(!1),d(!1),M(!1)},[]),j=ee.useCallback(async()=>{if(m)return;const z=h.trim();if(!z||!I)return;p(!0),x(null);const V=R,P=await EU(I,z);p(!1),P.ok?(f(""),x(`Sent to ${V}`),Tr("agent_steered")):P.error==="not_delivered"?x("Could not reach that agent (it may have finished)."):x("Could not send that message. Try again.")},[m,h,I,R]);return s?g.jsxs("div",{className:Mr("mt-4 rounded-2xl border border-white/[0.08] bg-[#050505] overflow-hidden transition-colors duration-300",c?"border-white/[0.18]":"hover:border-white/[0.12]",r),children:[g.jsxs("div",{className:"flex items-center justify-between gap-3 border-b border-white/[0.06] px-5 py-3",children:[g.jsxs("div",{className:"min-w-0",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(Um,{className:"h-4 w-4 text-[#666]"}),g.jsx("p",{className:"text-sm font-medium text-white",children:"Live prompt"})]}),g.jsx("p",{className:"mt-0.5 text-xs text-[#777]",children:"Connected"})]}),g.jsxs("div",{className:"flex shrink-0 items-center gap-2",children:[_?g.jsxs("div",{className:"rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-xs text-[#aaa]",children:["Target: ",g.jsx("span",{className:"text-white",children:R})]}):g.jsxs("div",{className:"flex items-center gap-1.5",children:[g.jsx("span",{className:"text-xs text-[#aaa]",children:"Target:"}),g.jsxs("div",{className:"relative",children:[g.jsxs("button",{type:"button",onClick:()=>M(z=>!z),onBlur:()=>requestAnimationFrame(()=>M(!1)),className:"inline-flex h-7 items-center gap-1 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 text-xs text-white transition-colors hover:border-white/[0.16] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20","aria-haspopup":"listbox","aria-expanded":E,children:[g.jsx("span",{className:"max-w-[140px] truncate",children:R}),g.jsx(ho,{className:"h-3.5 w-3.5 text-[#999]"})]}),E&&g.jsxs("div",{className:"absolute right-0 z-10 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-[#333] bg-[#0a0a0a] py-1 shadow-xl",role:"listbox",children:[g.jsx(m_,{label:"Root agent",active:w===Bs,onSelect:()=>{k(Bs),M(!1)}}),S.map(z=>g.jsx(m_,{label:z.name,active:w===z.id,onSelect:()=>{k(z.id),M(!1)}},z.id))]})]})]}),g.jsx("button",{type:"button",onClick:Z,className:"inline-flex h-7 w-7 items-center justify-center rounded-full text-[#777] transition-colors hover:bg-white/[0.06] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20","aria-label":"Collapse live prompt composer",children:g.jsx(ho,{className:"h-4 w-4"})})]})]}),g.jsx("div",{className:"px-5 pt-4 pb-3",children:g.jsx("textarea",{ref:a,rows:1,value:h,onChange:z=>f(z.target.value),onFocus:()=>d(!0),onBlur:()=>d(!1),onKeyDown:z=>{z.key==="Enter"&&!z.shiftKey&&(z.preventDefault(),j())},placeholder:"Send a live prompt to the running pentest…",maxLength:4e3,disabled:m,className:"block w-full resize-none border-0 bg-transparent p-0 text-[15px] leading-6 text-white placeholder:text-[#444] focus:outline-none disabled:opacity-60 max-h-[160px] overflow-y-auto"})}),g.jsxs("div",{className:"flex items-center justify-between gap-3 px-4 pb-4",children:[g.jsx("div",{className:"text-xs text-[#666]",children:y??"Press Enter to send."}),g.jsxs("button",{type:"button",onClick:z=>{z.stopPropagation(),j()},disabled:m||U,className:Mr("inline-flex h-10 min-w-[112px] items-center justify-center gap-2 rounded-full px-4 text-sm font-medium transition-colors",m||U?"bg-white/[0.08] text-[#666]":"bg-white text-black hover:bg-neutral-200"),children:[m?g.jsx(qs,{className:"h-4 w-4 animate-spin"}):g.jsx(zk,{className:"h-4 w-4",strokeWidth:2.5}),g.jsx("span",{children:"Send prompt"})]})]})]}):g.jsxs("button",{type:"button",onClick:B,className:Mr("mt-4 flex w-full items-center justify-between gap-3 rounded-2xl border border-white/[0.08] bg-[#050505] px-5 py-3 text-left transition-colors duration-300 hover:border-white/[0.12] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20",r),"aria-expanded":!1,"aria-label":"Expand live prompt composer",children:[g.jsxs("div",{className:"flex min-w-0 items-center gap-2",children:[g.jsx(Um,{className:"h-4 w-4 shrink-0 text-[#666]"}),g.jsx("span",{className:"truncate text-sm font-medium text-white",children:"Guide the agent"})]}),g.jsx(O_,{className:"h-4 w-4 shrink-0 text-[#777]"})]})}function m_({label:e,active:t,onSelect:r}){return g.jsx("button",{type:"button",role:"option","aria-selected":t,onMouseDown:a=>{a.preventDefault(),r()},className:Mr("block w-full truncate px-3 py-1.5 text-left text-xs transition-colors hover:bg-white/[0.06]",t?"text-white":"text-[#aaa]"),children:e})}const TU={completed:"bg-emerald-400",running:"bg-blue-400",waiting:"bg-yellow-400",stopped:"bg-[#888]",crashed:"bg-red-400",failed:"bg-red-400"},AU=80;function MU({open:e,agent:t,events:r,steerable:a,onClose:s}){const o=ee.useRef(null),c=ee.useRef(!1),[d,h]=ee.useState(e),[f,m]=ee.useState(e?"open":"closed"),[p,y]=ee.useState(!1),x=ee.useRef(t);ee.useEffect(()=>{t&&(x.current=t)},[t]);const _=t??x.current;ee.useEffect(()=>{if(e){h(!0),m("open");return}m("closed");const S=setTimeout(()=>h(!1),140);return()=>clearTimeout(S)},[e]),ee.useEffect(()=>{if(!d){y(!1);return}const S=requestAnimationFrame(()=>y(!0));return()=>cancelAnimationFrame(S)},[d]);const N=ee.useCallback(()=>{const S=o.current;S&&(c.current=S.scrollHeight-S.scrollTop-S.clientHeight{const S=o.current;!S||!c.current||requestAnimationFrame(()=>{S.scrollTo({top:S.scrollHeight,behavior:"smooth"})})},[r]),ee.useEffect(()=>{if(!d)return;const S=k=>{k.key==="Escape"&&s()};document.addEventListener("keydown",S);const w=document.body.style.overflow;return document.body.style.overflow="hidden",()=>{document.removeEventListener("keydown",S),document.body.style.overflow=w}},[d,s]),!d||!_?null:g.jsx("div",{"data-state":f,className:"agent-modal fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 sm:p-8",onClick:s,role:"dialog","aria-modal":"true","aria-label":`Agent ${_.name}`,children:g.jsxs("div",{className:"relative flex h-[60vh] w-[calc(100vw-4rem)] max-w-6xl flex-col overflow-hidden rounded-xl border border-[#222] bg-[#0a0a0a] shadow-2xl",onClick:S=>S.stopPropagation(),children:[g.jsxs("div",{className:"flex items-center justify-between gap-3 border-b border-[#222] px-5 py-3.5",children:[g.jsxs("div",{className:"flex min-w-0 items-center gap-2",children:[g.jsx("span",{className:`h-2 w-2 flex-shrink-0 rounded-full ${TU[_.status]??"bg-[#888]"}`}),g.jsx("span",{className:"truncate text-sm font-semibold text-white",children:_.name}),g.jsx("span",{className:"flex-shrink-0 font-mono text-xs text-[#555]",children:_.id})]}),g.jsx("button",{type:"button",onClick:s,"aria-label":"Close",className:"flex-shrink-0 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white",children:g.jsx(vp,{className:"h-4 w-4"})})]}),g.jsx("div",{ref:o,onScroll:N,className:"flex-1 overflow-y-auto p-5",children:p&&g.jsx(uU,{agent:_,events:r,showHeader:!1})}),a&&g.jsx("div",{className:"border-t border-[#222] px-5 py-3",children:g.jsx(fS,{agents:[_],fixedAgentId:_.id,className:"mt-0"})})]})})}var hS={color:void 0,size:void 0,className:void 0,style:void 0,attr:void 0},p_=da.createContext&&da.createContext(hS),OU=["attr","size","title"];function RU(e,t){if(e==null)return{};var r,a,s=jU(e,t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(e);for(a=0;ada.createElement(t.tag,Uu({key:r},t.attr),mS(t.child)))}function pg(e){return t=>da.createElement(IU,Bu({attr:Uu({},e.attr)},t),mS(e.child))}function IU(e){var t=r=>{var a=e.attr,s=e.size,o=e.title,c=RU(e,OU),d=s||r.size||"1em",h;return r.className&&(h=r.className),e.className&&(h=(h?h+" ":"")+e.className),da.createElement("svg",Bu({stroke:"currentColor",fill:"currentColor",strokeWidth:"0"},r.attr,a,c,{className:h,style:Uu(Uu({color:e.color||r.color},r.style),e.style),height:d,width:d,xmlns:"http://www.w3.org/2000/svg"}),o&&da.createElement("title",null,o),e.children)};return p_!==void 0?da.createElement(p_.Consumer,null,r=>t(r)):t(hS)}function BU(e){return pg({attr:{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round"},child:[{tag:"circle",attr:{cx:"5",cy:"6",r:"3"},child:[]},{tag:"path",attr:{d:"M5 9v12"},child:[]},{tag:"circle",attr:{cx:"19",cy:"18",r:"3"},child:[]},{tag:"path",attr:{d:"m15 9-3-3 3-3"},child:[]},{tag:"path",attr:{d:"M12 6h5a2 2 0 0 1 2 2v7"},child:[]}]})(e)}function UU(e){return pg({attr:{viewBox:"0 0 16 16",fill:"currentColor"},child:[{tag:"path",attr:{d:"M15 4.95703C15 4.58711 14.8563 4.24054 14.5949 3.97992L12.0096 1.39234C11.4879 0.86922 10.5788 0.86922 10.0571 1.39234L8 3.45119V3.32321C8 2.55068 7.37187 1.922 6.6 1.922H2.4C1.62813 1.922 1 2.55068 1 3.32321V13.5988C1 14.3713 1.62813 15 2.4 15H12.6667C13.4385 15 14.0667 14.3713 14.0667 13.5988V9.39514C14.0667 8.62261 13.4385 7.99393 12.6667 7.99393H12.5379L14.5949 5.93508C14.8553 5.67445 15 5.32602 15 4.95703ZM2.4 2.85521H6.6C6.85667 2.85521 7.06667 3.06446 7.06667 3.32228V7.99299H1.93333V3.32228C1.93333 3.06446 2.14333 2.85521 2.4 2.85521ZM1.93333 13.5979V8.92714H7.06667V14.0649H2.4C2.14333 14.0649 1.93333 13.8547 1.93333 13.5979ZM13.1333 9.39421V13.5979C13.1333 13.8547 12.9233 14.0649 12.6667 14.0649H8V8.92714H12.6667C12.9233 8.92714 13.1333 9.13638 13.1333 9.39421ZM8 7.99299V6.46287L9.5288 7.99299H8ZM13.9351 5.2737L11.3488 7.86221C11.1789 8.03223 10.8859 8.03223 10.716 7.86221L8.12973 5.2737C8.0448 5.18963 7.99813 5.07753 7.99813 4.95796C7.99813 4.83839 8.0448 4.7263 8.12973 4.64129L10.716 2.05278C10.8009 1.96777 10.9129 1.92106 11.0324 1.92106C11.1519 1.92106 11.2639 1.96777 11.3488 2.05278L13.9351 4.64129C14.02 4.72536 14.0667 4.83746 14.0667 4.95703C14.0667 5.0766 14.02 5.1887 13.9351 5.2737Z"},child:[]}]})(e)}function pS(e){return pg({attr:{viewBox:"0 0 512 512"},child:[{tag:"path",attr:{fill:"none",strokeLinecap:"round",strokeMiterlimit:"10",strokeWidth:"32",d:"M431 320.6c-1-3.6 1.2-8.6 3.3-12.2a34 34 0 0 1 2.1-3.1A162 162 0 0 0 464 215c.3-92.2-77.5-167-173.7-167-83.9 0-153.9 57.1-170.3 132.9a160.7 160.7 0 0 0-3.7 34.2c0 92.3 74.8 169.1 171 169.1 15.3 0 35.9-4.6 47.2-7.7s22.5-7.2 25.4-8.3a26.4 26.4 0 0 1 9.3-1.7 26 26 0 0 1 10.1 2l56.7 20.1a13.5 13.5 0 0 0 3.9 1 8 8 0 0 0 8-8 13 13 0 0 0-.5-2.7z"},child:[]},{tag:"path",attr:{fill:"none",strokeLinecap:"round",strokeMiterlimit:"10",strokeWidth:"32",d:"M66.46 232a146.23 146.23 0 0 0 6.39 152.67c2.31 3.49 3.61 6.19 3.21 8s-11.93 61.87-11.93 61.87a8 8 0 0 0 2.71 7.68A8.17 8.17 0 0 0 72 464a7.3 7.3 0 0 0 2.91-.6l56.21-22a15.7 15.7 0 0 1 12 .2c18.94 7.38 39.88 12 60.83 12A159.2 159.2 0 0 0 284 432.11"},child:[]}]})(e)}const HU=[{icon:_C,label:"PR security reviews"},{icon:lT,label:"Attack surface monitoring"},{icon:ET,label:"Real-time threat intelligence"},{icon:Pk,label:"Scheduled pentesting"},{icon:yT,label:"One-click autofix"},{icon:FC,label:"Jira, Linear & Slack integrations"}];function $U({open:e,onClose:t,description:r,source:a="sidebar"}){const[s,o]=ee.useState(e),[c,d]=ee.useState(e?"open":"closed");return ee.useEffect(()=>{if(e){o(!0),d("open");return}d("closed");const h=setTimeout(()=>o(!1),200);return()=>clearTimeout(h)},[e]),ee.useEffect(()=>{if(!s)return;const h=m=>{m.key==="Escape"&&t()};document.addEventListener("keydown",h);const f=document.body.style.overflow;return document.body.style.overflow="hidden",()=>{document.removeEventListener("keydown",h),document.body.style.overflow=f}},[s,t]),s?g.jsx("div",{"data-state":c,className:"dialog-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4",onClick:t,role:"dialog","aria-modal":"true","aria-label":"Upgrade your plan",children:g.jsxs("div",{"data-state":c,className:"dialog-panel relative w-full max-w-md rounded-2xl border border-[#222] bg-black p-6 shadow-lg sm:rounded-lg",onClick:h=>h.stopPropagation(),children:[g.jsx("button",{type:"button",onClick:t,"aria-label":"Close",className:"absolute right-4 top-4 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white",children:g.jsx(vp,{className:"h-4 w-4"})}),g.jsxs("div",{children:[g.jsx("h2",{className:"text-lg text-white",children:"Available in Strix Cloud"}),r&&g.jsx("p",{className:"mt-2 text-base leading-relaxed text-[#e5e5e5]",children:r})]}),g.jsxs("div",{className:"space-y-4 pt-4",children:[g.jsxs("div",{className:"rounded-xl border border-[#333] bg-[#0a0a0a] p-4 sm:rounded-lg",children:[g.jsxs("div",{className:"mb-3 flex items-center gap-2",children:[g.jsx(Um,{className:"h-4 w-4 text-blue-400"}),g.jsx("span",{className:"text-sm font-medium text-white",children:"Strix Cloud also includes"})]}),g.jsx("ul",{className:"space-y-2 text-sm text-[#888]",children:HU.map(h=>g.jsxs("li",{className:"flex items-center gap-2",children:[g.jsx(h.icon,{className:"h-3.5 w-3.5 text-[#555]"}),h.label]},h.label))})]}),g.jsxs("div",{className:"flex flex-col gap-2",children:[g.jsxs("a",{href:ha($u,"upgrade_try_free"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("upgrade_try_free",a),className:"flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-white px-4 text-sm font-semibold text-black transition-colors hover:bg-neutral-200",children:["Open Strix Cloud",g.jsx(ry,{className:"h-3.5 w-3.5"})]}),g.jsxs("a",{href:ha(TT,"upgrade_view_plans"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("upgrade_view_plans",a),className:"flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-[#333] px-4 text-sm font-medium text-[#888] transition-colors hover:border-[#555] hover:text-white",children:["Learn more",g.jsx(ry,{className:"h-3 w-3"})]})]})]})]})}):null}const Lm=160,zm=260,ro=400,qU=140,b_="strix_viewer_sidebar_width",x_="strix_viewer_sidebar_collapsed";function PU(e,t){try{const r=localStorage.getItem(e),a=r?parseInt(r,10):NaN;return Number.isFinite(a)?a:t}catch{return t}}function FU({view:e,onSelectView:t,issuesCount:r,agentCount:a,runCount:s,finished:o,verified:c,email:d,onOpenEmail:h,onOpenHistory:f,onForget:m}){var z;const[p,y]=ee.useState(()=>{const V=PU(b_,zm);return Math.min(ro,Math.max(Lm,V))}),[x,_]=ee.useState(()=>{try{return localStorage.getItem(x_)==="1"}catch{return!1}}),[N,S]=ee.useState(!1),[w,k]=ee.useState(!1),[E,M]=ee.useState(null),I=ee.useRef(null),R=(V,P)=>{jr(V,"sidebar"),M(P)},U=ee.useCallback(V=>{y(V);try{localStorage.setItem(b_,String(V))}catch{}},[]),B=ee.useCallback(V=>{_(V);try{localStorage.setItem(x_,V?"1":"0")}catch{}},[]),Z=ee.useCallback(()=>{B(!1),U(zm)},[B,U]),j=ee.useCallback(V=>{V.preventDefault(),S(!0)},[]);return ee.useEffect(()=>{if(!N||x)return;const V=T=>{const $=T.clientX;$>=Lm&&$<=ro?y($):$>ro&&y(ro)},P=T=>{const $=T.clientX;${window.removeEventListener("mousemove",V),window.removeEventListener("mouseup",P)}},[N,x,B,U]),ee.useEffect(()=>{if(!w)return;const V=P=>{I.current&&!I.current.contains(P.target)&&k(!1)};return document.addEventListener("mousedown",V),()=>document.removeEventListener("mousedown",V)},[w]),g.jsxs(g.Fragment,{children:[x&&g.jsx("div",{className:"fixed left-0 top-0 z-40 hidden h-full w-4 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.08)] lg:block",onClick:Z,title:"Expand sidebar"}),g.jsxs("aside",{className:Mr("sticky top-0 z-20 hidden h-screen flex-shrink-0 flex-col overflow-hidden border-r border-[rgba(255,255,255,0.08)] bg-black lg:flex",!N&&"transition-[width] duration-200 ease-out"),style:{width:x?0:p},children:[g.jsx("header",{className:"relative flex flex-col gap-1 pt-1 min-w-[160px]",children:g.jsx("div",{className:"flex flex-row py-1 px-2",children:g.jsxs("div",{className:"flex h-10 w-full flex-row items-center",children:[g.jsxs("a",{href:ha("https://app.strix.ai","logo"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("logo","sidebar"),className:"flex flex-1 flex-row items-center gap-2 rounded-md py-2 pl-2.5 pr-1 min-w-0 transition-colors hover:bg-[rgba(255,255,255,0.06)]",title:"Open Strix Cloud",children:[g.jsx("span",{className:"flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500",style:{width:20,height:20},children:g.jsx("span",{className:"text-[10px] font-semibold text-white",children:"S"})}),g.jsxs("span",{className:"flex flex-1 flex-row items-center gap-2 min-w-0",children:[g.jsx("span",{className:"truncate min-w-0 text-[14px] font-medium text-[#ededed]",children:"Strix"}),g.jsx("span",{className:"flex h-5 flex-shrink-0 items-center rounded px-2 text-[11px] font-medium text-[#888] bg-[rgba(255,255,255,0.08)]",children:"Local"})]})]}),g.jsx("a",{href:ha("https://app.strix.ai","logo"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("logo","sidebar"),className:"flex flex-none items-center rounded-md px-1.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]","aria-label":"Open Strix Cloud",children:g.jsx(Wk,{className:"h-4 w-4 text-[#666]"})})]})})}),g.jsx("nav",{className:"relative min-w-[160px] flex-1 overflow-y-auto overflow-x-clip scrollbar-thin pb-10 pt-2",children:g.jsxs("div",{className:"relative flex flex-col gap-px px-2",children:[g.jsx(yi,{icon:g.jsx(GU,{}),label:"Pentest Overview",active:e==="overview",onClick:()=>t("overview")}),g.jsx(yi,{icon:g.jsx(pT,{className:"h-4 w-4"}),label:"Issues",count:r>0?r:void 0,active:e==="issues",onClick:()=>t("issues")}),a>0&&g.jsx(yi,{icon:g.jsx(Ao,{className:"h-4 w-4"}),label:"Agents",count:a,active:e==="agents",onClick:()=>t("agents")}),g.jsx(yi,{icon:g.jsx(Vs,{className:"h-4 w-4"}),label:"Past runs",count:s>0?s:void 0,active:e==="history",onClick:f}),o&&g.jsx(yi,{icon:g.jsx(yp,{className:"h-4 w-4"}),label:"Export report",active:e==="email",onClick:h}),g.jsx(yi,{icon:g.jsx(pS,{className:"h-4 w-4"}),label:"Feedback & support",active:e==="feedback",onClick:()=>t("feedback")}),g.jsx("hr",{className:"mx-0 my-1 h-px w-full border-0 bg-[rgba(255,255,255,0.08)]"}),g.jsx(yi,{icon:g.jsx(BU,{className:"h-4 w-4"}),label:"PR Security Reviews",active:!1,onClick:()=>R("pr_reviews","Strix reviews every pull request and flags exploitable changes before they merge.")}),g.jsx(yi,{icon:g.jsx(UU,{className:"h-4 w-4"}),label:"Integrations",active:!1,onClick:()=>R("integrations","Sync findings to Jira, Linear, and Slack so fixes happen where your team already works.")}),g.jsx(yi,{icon:g.jsx(bT,{className:"h-4 w-4"}),label:"Members",active:!1,onClick:()=>R("members","Invite your team, set roles, and share findings and run history across your org.")})]})}),g.jsx("section",{className:"flex min-w-[160px] flex-col gap-0.5",ref:I,children:g.jsxs("div",{className:"relative p-2",children:[c&&d?g.jsxs("button",{onClick:()=>k(V=>!V),className:"relative flex w-full cursor-pointer items-center gap-2 rounded-md bg-transparent px-2.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]",children:[g.jsx("span",{className:"flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500",style:{width:20,height:20},children:g.jsx("span",{className:"text-[9px] font-semibold text-white",children:((z=d[0])==null?void 0:z.toUpperCase())||"U"})}),g.jsxs("span",{className:"flex min-w-0 flex-1 flex-col text-left",children:[g.jsx("span",{className:"truncate text-[13px] font-medium text-[#ededed]",children:d}),g.jsx("span",{className:"truncate text-[11px] text-[#555]",children:"Linked to this machine"})]})]}):g.jsxs("div",{className:"flex items-center gap-2 rounded-md px-2.5 py-2",children:[g.jsx("span",{className:"flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500",style:{width:20,height:20},children:g.jsx("span",{className:"text-[9px] font-semibold text-white",children:"S"})}),g.jsx("span",{className:"flex min-w-0 flex-1 flex-col text-left",children:g.jsx("span",{className:"truncate text-[13px] font-medium text-[#ededed]",children:"Local viewer"})})]}),w&&c&&d&&g.jsxs("div",{className:"absolute bottom-full left-2 right-2 z-50 mb-1 overflow-hidden rounded-lg border border-[#333] bg-black shadow-xl",children:[g.jsxs("div",{className:"border-b border-[#333] px-3 py-2",children:[g.jsx("p",{className:"truncate text-[13px] font-medium text-white",children:"Linked email"}),g.jsx("p",{className:"truncate text-[11px] text-[#666]",children:d})]}),g.jsxs("button",{onClick:()=>{k(!1),m()},className:"flex w-full items-center gap-2 px-3 py-2 text-[13px] text-[#888] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-red-400",children:[g.jsx(BC,{className:"h-4 w-4"}),"Forget this email"]})]})]})}),g.jsx("div",{className:"group absolute right-0 top-0 z-30 h-full w-1 cursor-col-resize",onMouseDown:j,children:g.jsx("div",{className:Mr("absolute right-0 top-0 h-full w-px bg-[rgba(255,255,255,0.08)] transition-all duration-100",N?"w-0.5 bg-[rgba(255,255,255,0.3)]":"group-hover:bg-[rgba(255,255,255,0.2)]")})})]}),N&&g.jsx("div",{className:"fixed inset-0 z-10 cursor-col-resize"}),g.jsx($U,{open:E!==null,description:E??"",source:"sidebar",onClose:()=>M(null)})]})}function yi({icon:e,label:t,active:r,onClick:a,count:s}){return g.jsxs("button",{onClick:a,className:Mr("group flex h-9 w-full origin-left flex-row items-center rounded-md transition-colors",r?"bg-[rgba(255,255,255,0.12)] text-white":"text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-[#ededed]"),children:[g.jsx("div",{className:"grid flex-none place-content-center",style:{width:36,height:36},children:e}),g.jsx("span",{className:"min-w-0 flex-1 truncate text-left text-[14px] font-medium",children:t}),s!=null&&g.jsx("span",{className:"mr-2 flex-none rounded-full border border-white/10 px-2 py-0.5 text-[10px] tabular-nums leading-none text-[#777]",children:s})]})}function GU(){return g.jsx("svg",{style:{width:16,height:16,color:"currentcolor"},viewBox:"0 0 16 16",fill:"currentColor",children:g.jsx("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M2.5 5.5V2.5H5.5V5.5H2.5ZM1 2C1 1.44772 1.44772 1 2 1H6C6.55228 1 7 1.44772 7 2V6C7 6.55228 6.55228 7 6 7H2C1.44772 7 1 6.55228 1 6V2ZM2.5 13.5V10.5H5.5V13.5H2.5ZM1 10C1 9.44772 1.44772 9 2 9H6C6.55228 9 7 9.44772 7 10V14C7 14.5523 6.55228 15 6 15H2C1.44772 15 1 14.5523 1 14V10ZM10.5 2.5V5.5H13.5V2.5H10.5ZM10 1C9.44772 1 9 1.44772 9 2V6C9 6.55228 9.44772 7 10 7H14C14.5523 7 15 6.55228 15 6V2C15 1.44772 14.5523 1 14 1H10ZM10.5 13.5V10.5H13.5V13.5H10.5ZM9 10C9 9.44772 9.44772 9 10 9H14C14.5523 9 15 9.44772 15 10V14C15 14.5523 14.5523 15 14 15H10C9.44772 15 9 14.5523 9 14V10Z"})})}const y_={work_email_required:"Please use your work email, not a personal one.",rate_limited:"Too many requests. Wait a minute and try again.",invalid_email:"That email does not look right. Check it and try again.",unavailable:"The email service is unavailable right now. Try again shortly."},VU=new Set(["gmail.com","googlemail.com","yahoo.com","ymail.com","outlook.com","hotmail.com","live.com","icloud.com","me.com","aol.com","proton.me","protonmail.com","gmx.com","mail.com"]);function YU({onVerified:e}){const[t,r]=ee.useState("email"),[a,s]=ee.useState(""),[o,c]=ee.useState(""),[d,h]=ee.useState(!1),[f,m]=ee.useState(null),[p,y]=ee.useState(null),x=async()=>{const N=a.trim();if(!N){m("Enter your email to continue.");return}const S=N.slice(N.lastIndexOf("@")+1).toLowerCase();if(VU.has(S)){Tr("work_email_required"),m(y_.work_email_required);return}h(!0),m(null);const w=await uS(N);h(!1),w.ok?(Tr("email_submitted",{purpose:"verify"}),y(`We sent a 6-digit code to ${N}.`),r("code")):(w.error==="work_email_required"&&Tr("work_email_required"),m(y_[w.error]??"Could not send a code. Try again."))},_=async()=>{const N=o.trim();if(N.length<4){m("Enter the 6-digit code from your email.");return}h(!0),m(null);const S=await dS(a.trim(),N);if(h(!1),!S.verified){m("That code did not match. Check it and try again.");return}Tr("email_verified",{purpose:"verify"}),e()};return g.jsxs("div",{className:"mx-auto mt-5 max-w-sm text-left",children:[f&&g.jsxs("div",{className:"mb-3 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2",children:[g.jsx(Hu,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs text-red-300",children:f})]}),p&&!f&&g.jsx("p",{className:"mb-3 text-xs text-[#888]",children:p}),t==="email"?g.jsxs("form",{className:"space-y-3",onSubmit:N=>{N.preventDefault(),x()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your work email"}),g.jsx("input",{type:"email",autoFocus:!0,value:a,onChange:N=>s(N.target.value),placeholder:"you@company.com",className:"w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}}),g.jsx("span",{className:"mt-1.5 block text-[11px] text-[#666]",children:"Use your work email."})]}),g.jsxs("button",{type:"submit",disabled:d,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[d&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),"Send me a code"]})]}):g.jsxs("form",{className:"space-y-3",onSubmit:N=>{N.preventDefault(),_()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"6-digit code"}),g.jsx("input",{inputMode:"numeric",autoFocus:!0,value:o,onChange:N=>c(N.target.value.replace(/\D/g,"").slice(0,6)),placeholder:"123456",className:"w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}})]}),g.jsxs("button",{type:"submit",disabled:d,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[d&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),"Verify"]}),g.jsx("button",{type:"button",onClick:()=>{r("email"),m(null),y(null)},className:"w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]",children:"Use a different email"})]})]})}const XU=[{key:"critical",dot:"bg-red-500",text:"text-red-500"},{key:"high",dot:"bg-orange-500",text:"text-orange-500"},{key:"medium",dot:"bg-yellow-500",text:"text-yellow-500"},{key:"low",dot:"bg-blue-500",text:"text-blue-500"}];function KU({counts:e}){const t=XU.filter(r=>e[r.key]>0);return t.length===0?g.jsx("span",{className:"text-xs text-[#555]",children:"No findings"}):g.jsx("div",{className:"flex items-center gap-3",children:t.map(r=>g.jsxs("div",{className:"flex items-center gap-1.5",children:[g.jsx("span",{className:`h-2 w-2 rounded-full ${r.dot}`,"aria-hidden":"true"}),g.jsx("span",{className:`text-xs tabular-nums ${r.text}`,children:e[r.key]})]},r.key))})}function ZU(e){if(!e)return null;const t=e.trim().replace(" UTC","Z").replace(" ","T"),r=new Date(t);return Number.isNaN(r.getTime())?null:r.toLocaleString(void 0,{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}function v_(e){if(!e)return null;const t=e.trim().replace(" UTC","Z").replace(" ","T"),r=new Date(t);if(Number.isNaN(r.getTime()))return null;const a=Date.now()-r.getTime(),s=Math.floor(a/6e4);if(s<1)return"just now";if(s<60)return`${s}m ago`;const o=Math.floor(s/60);if(o<24)return`${o}h ago`;const c=Math.floor(o/24);return c<7?`${c}d ago`:ZU(e)}function QU({runs:e,activeRun:t,onSelectRun:r,onVerified:a}){const s=(e==null?void 0:e.count)??0,[o,c]=ee.useState(!1);return!e||e.locked?g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center",children:[g.jsx("div",{className:"mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-xl",style:{border:"1px solid #2a2a2a",background:"rgba(255,255,255,0.04)"},children:g.jsx(Vs,{className:"h-5 w-5 text-[#888]","aria-hidden":"true"})}),g.jsx("h2",{className:"text-base font-semibold text-white",children:"Browse every run on this machine"}),g.jsxs("p",{className:"mx-auto mt-1.5 max-w-md text-sm text-[#888]",children:["You have ",s," past ",s===1?"run":"runs"," on this machine."]}),o?g.jsxs(g.Fragment,{children:[g.jsx("p",{className:"mx-auto mt-3 max-w-sm text-xs text-[#666]",children:"Verify your email with a one-time code to unlock the full history."}),g.jsx(YU,{onVerified:a})]}):g.jsx("button",{onClick:()=>{jr("history_unlock","past_runs"),c(!0)},className:"mt-4 cursor-pointer rounded-lg bg-white px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90",children:"View runs"}),g.jsxs("p",{className:"mt-4 flex items-center justify-center gap-1.5 text-xs text-[#555]",children:[g.jsx(B_,{className:"h-3.5 w-3.5","aria-hidden":"true"}),"Or open one from the CLI with"," ",g.jsx("code",{className:"font-mono text-[#888]",children:"strix view "})]})]}):e.runs.length===0?g.jsx("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]",children:"No past runs found on this machine yet."}):g.jsx("div",{className:"space-y-2",children:e.runs.map(d=>{const h=d.name===t,f=v_(d.start_time)??v_(d.end_time),m=bo(d.target,d.name);return g.jsxs("button",{onClick:()=>r(d.name),className:`animate-card-in group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${h?"border-[#444] bg-[rgba(255,255,255,0.04)]":"border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"}`,children:[g.jsxs("div",{className:"min-w-0 flex-1",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"truncate text-sm font-medium text-white",children:m}),h&&g.jsx("span",{className:"rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400",style:{border:"1px solid rgba(16,185,129,0.3)"},children:"Active"})]}),g.jsxs("div",{className:"mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-[#666]",children:[d.scan_mode&&g.jsx("span",{className:"capitalize",children:d.scan_mode}),d.scan_mode&&(f||d.status)&&g.jsx("span",{className:"text-[#333]",children:"Β·"}),f&&g.jsx("span",{children:f}),f&&d.status&&g.jsx("span",{className:"text-[#333]",children:"Β·"}),d.status&&g.jsx("span",{className:"capitalize",children:d.status})]})]}),g.jsx(KU,{counts:d.severity_counts}),g.jsx(Kk,{className:"h-4 w-4 flex-shrink-0 text-[#555] transition-colors group-hover:text-[#aaa]","aria-hidden":"true"})]},d.name)})})}const __={work_email_required:"Please use your work email, not a personal one.",rate_limited:"Too many requests. Wait a minute and try again.",invalid_email:"That email does not look right. Check it and try again.",unavailable:"The email service is unavailable right now. Try again shortly."},WU={forbidden:"This email was unsubscribed from Strix, so we cannot send to it.",too_large:"This report is too large to email. Try a smaller run.",unavailable:"The email service is unavailable right now. Try again shortly."},JU=new Set(["gmail.com","googlemail.com","yahoo.com","ymail.com","outlook.com","hotmail.com","live.com","icloud.com","me.com","aol.com","proton.me","protonmail.com","gmx.com","mail.com"]);function eH({activeRun:e,auth:t,purpose:r,skipDisclosure:a=!1,onAuthChanged:s,onExit:o}){const c=(t==null?void 0:t.verified)===!0,d=r==="verify",[h,f]=ee.useState(()=>d?"email":a?c?"sending":"email":"disclosure"),[m,p]=ee.useState((t==null?void 0:t.email)??""),[y,x]=ee.useState(""),[_,N]=ee.useState(!1),[S,w]=ee.useState(null),[k,E]=ee.useState(null),[M,I]=ee.useState(""),[R,U]=ee.useState(""),[B,Z]=ee.useState(!1),[j,z]=ee.useState(""),V=ee.useRef(!1),P=async()=>{f("sending"),w(null);const K=await CU(e);if(K.ok){Tr("report_sent"),I(K.password),U(K.filename),f("password");return}if(K.error==="reverify"||K.error==="unverified"){E("Your verification expired. Enter your email to verify again."),f("email");return}w(WU[K.error]??"Could not send the report. Try again."),f("disclosure")},T=()=>{w(null),E(null),c?P():f("email")};ee.useEffect(()=>{!d&&a&&c&&!V.current&&(V.current=!0,P())},[]);const $=async()=>{const K=m.trim();if(!K){w("Enter your email to continue.");return}const C=K.slice(K.lastIndexOf("@")+1).toLowerCase();if(JU.has(C)){Tr("work_email_required"),w(__.work_email_required);return}N(!0),w(null);const D=await uS(K);N(!1),D.ok?(Tr("email_submitted",{purpose:r}),E(`We sent a 6-digit code to ${K}.`),f("code")):(D.error==="work_email_required"&&Tr("work_email_required"),w(__[D.error]??"Could not send a code. Try again."))},O=async()=>{const K=y.trim();if(K.length<4){w("Enter the 6-digit code from your email.");return}N(!0),w(null);const C=await dS(m.trim(),K);if(N(!1),!C.verified){w("That code did not match. Check it and try again.");return}Tr("email_verified",{purpose:r}),z(C.email),s(),d?o("history"):P()},H=async()=>{try{await navigator.clipboard.writeText(M),Z(!0),setTimeout(()=>Z(!1),1500)}catch{}},X=j||(t==null?void 0:t.email)||m.trim();return g.jsxs("div",{className:"mx-auto max-w-xl space-y-4",children:[g.jsxs("button",{onClick:()=>o(d?"history":"overview"),className:"cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white",children:[g.jsx(xp,{className:"h-4 w-4"}),d?"Back to past runs":"Back to results"]}),g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(yp,{className:"h-5 w-5 text-[#888]","aria-hidden":"true"}),g.jsx("h1",{className:"text-2xl font-semibold text-white",children:d?"Verify your email":"Export report to PDF"})]}),g.jsxs("div",{className:"w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6",style:{border:"1px solid #2a2a2a"},children:[g.jsx("p",{className:"mb-4 text-xs text-[#666]",children:d?"We send a one-time code to confirm it is you.":"Verified by a one-time code sent to your email"}),S&&g.jsxs("div",{className:"mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2",children:[g.jsx(Hu,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs text-red-300",children:S})]}),k&&!S&&h!=="password"&&g.jsx("p",{className:"mb-4 text-xs text-[#888]",children:k}),h==="disclosure"&&g.jsxs("div",{className:"space-y-4",children:[g.jsxs("div",{className:"space-y-2.5 rounded-lg p-3.5",style:{border:"1px solid #222",background:"rgba(255,255,255,0.02)"},children:[g.jsxs("div",{className:"flex items-start gap-2.5",children:[g.jsx(I_,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsxs("p",{className:"text-xs leading-relaxed text-[#aaa]",children:["We email an ",g.jsx("span",{className:"text-white",children:"encrypted PDF"}),". Nothing else leaves your machine."]})]}),g.jsxs("div",{className:"flex items-start gap-2.5",children:[g.jsx(zC,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]","aria-hidden":"true"}),g.jsx("p",{className:"text-xs leading-relaxed text-[#aaa]",children:"Only you hold the password; Strix can't read it."})]})]}),g.jsx("button",{onClick:T,className:"w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90",children:"Export report"}),c&&(t==null?void 0:t.email)&&g.jsxs("p",{className:"text-center text-xs text-[#666]",children:["Sending to ",t.email]})]}),h==="email"&&g.jsxs("form",{className:"space-y-4",onSubmit:K=>{K.preventDefault(),$()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your work email"}),g.jsx("input",{type:"email",autoFocus:!0,value:m,onChange:K=>p(K.target.value),placeholder:"you@company.com",className:"w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}})]}),g.jsxs("button",{type:"submit",disabled:_,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[_&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),"Send me a code"]})]}),h==="code"&&g.jsxs("form",{className:"space-y-4",onSubmit:K=>{K.preventDefault(),O()},children:[g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"6-digit code"}),g.jsx("input",{inputMode:"numeric",autoFocus:!0,value:y,onChange:K=>x(K.target.value.replace(/\D/g,"").slice(0,6)),placeholder:"123456",className:"w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]",style:{border:"1px solid #2a2a2a"}})]}),g.jsxs("button",{type:"submit",disabled:_,className:"flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:[_&&g.jsx(qs,{className:"h-4 w-4 animate-spin","aria-hidden":"true"}),d?"Verify":"Verify and send"]}),g.jsx("button",{type:"button",onClick:()=>{f("email"),w(null),E(null)},className:"w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]",children:"Use a different email"})]}),h==="sending"&&g.jsxs("div",{className:"flex flex-col items-center gap-3 py-8",children:[g.jsx(qs,{className:"h-6 w-6 animate-spin text-white","aria-hidden":"true"}),g.jsx("p",{className:"text-sm text-[#aaa]",children:"Generating and encrypting locally..."})]}),h==="password"&&g.jsxs("div",{className:"space-y-4",children:[g.jsxs("div",{className:"flex items-start gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5",children:[g.jsx(Gs,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsxs("p",{className:"text-xs text-emerald-200",children:["Sent to ",X,". Open the attached PDF with this password."]})]}),g.jsxs("div",{children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your one-time password"}),g.jsxs("div",{className:"flex items-center gap-2 rounded-lg bg-black p-3",style:{border:"1px solid #2a2a2a"},children:[g.jsx("code",{className:"flex-1 break-all font-mono text-base text-white",children:M}),g.jsxs("button",{onClick:H,className:"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs text-[#aaa] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white",style:{border:"1px solid #2a2a2a"},children:[B?g.jsx(Gs,{className:"h-3.5 w-3.5"}):g.jsx(mo,{className:"h-3.5 w-3.5"}),B?"Copied":"Copy"]})]}),g.jsxs("p",{className:"mt-2 text-xs text-[#666]",children:["Save this now. Strix never stores it, so we cannot show it again. File:"," ",g.jsx("span",{className:"font-mono text-[#888]",children:R})]})]}),g.jsx("button",{onClick:()=>o("overview"),className:"w-full cursor-pointer rounded-lg px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[rgba(255,255,255,0.06)]",style:{border:"1px solid #2a2a2a"},children:"Done"})]})]})]})}function la(e){return e&&typeof e=="object"&&!Array.isArray(e)?e:{}}function io(e){return Array.isArray(e)?e:[]}function nr(e){return typeof e=="string"&&e.trim()?e:null}function Ba(e){return typeof e=="number"&&Number.isFinite(e)?e:null}function tH(e){return e.replace(/_/g," ")}function w_(e){return e&&e.charAt(0).toUpperCase()+e.slice(1)}function nH(e){if(e==null||e<0)return"n/a";const t=Math.floor(e/3600),r=Math.floor(e%3600/60),a=e%60;return t?`${t}h ${r}m ${a}s`:r?`${r}m ${a}s`:`${a}s`}function wn({label:e,children:t}){return g.jsxs("div",{className:"grid grid-cols-[7rem_1fr] gap-3 items-baseline",children:[g.jsx("dt",{className:"text-[11px] uppercase tracking-wide text-[#666]",children:e}),g.jsx("dd",{className:"min-w-0 break-words text-sm text-[#ddd]",children:t})]})}function rH({raw:e,durationSeconds:t}){const[r,a]=ee.useState(!0),s=io(e.targets_info).map(P=>{const T=la(P),$=nr(T.original)??nr(la(T.details).target_url)??"unknown target",O=nr(T.type);return{display:$,type:O?tH(O):null}}),o=nr(e.instruction),c=w_(nr(e.scan_mode)),d=nr(e.scope_mode),h=la(e.diff_scope),f=h.active===!0,m=nr(h.mode),p=nr(e.diff_base),y=e.non_interactive===!0,x=io(e.local_sources).map(P=>{if(typeof P=="string")return P;const T=la(P);return nr(T.source_path)??nr(T.target_path)??""}).filter(Boolean),_=w_(nr(e.status));let N=d??"auto";f&&(N+=` (diff${m?`: ${m}`:""}${p?` vs ${p}`:""})`);const S=la(e.llm_usage),w=Object.keys(S).length>0,k=io(S.agents).map(la),E=Array.from(new Set(k.map(P=>nr(P.model)).filter(P=>!!P))),M=Ba(S.requests),I=Ba(S.input_tokens),R=Ba(la(io(S.input_tokens_details)[0]).cached_tokens),U=Ba(S.output_tokens),B=Ba(la(io(S.output_tokens_details)[0]).reasoning_tokens),Z=Ba(S.total_tokens),j=Ba(S.cost),z=nr(e.auth_mode)==="subscription",V=(P,T)=>g.jsxs("span",{className:"text-[#666]",children:[" (",Ds(P)," ",T,")"]});return g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsxs("button",{type:"button",onClick:()=>a(P=>!P),"aria-expanded":r,className:"flex w-full cursor-pointer items-center gap-2 text-left",children:[g.jsx(OC,{className:"h-4 w-4 text-[#888]","aria-hidden":"true"}),g.jsx("h2",{className:"text-sm font-semibold text-white",children:"Run details"}),r?g.jsx(O_,{className:"ml-auto h-4 w-4 text-[#666]","aria-hidden":"true"}):g.jsx(ho,{className:"ml-auto h-4 w-4 text-[#666]","aria-hidden":"true"})]}),r&&g.jsxs("div",{className:"mt-4 grid grid-cols-1 gap-x-8 gap-y-6 md:grid-cols-2",children:[g.jsxs("section",{children:[g.jsx("h3",{className:"mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]",children:"Configuration"}),g.jsxs("dl",{className:"space-y-2.5",children:[s.length>0&&g.jsx(wn,{label:"Targets",children:g.jsx("div",{className:"space-y-1",children:s.map((P,T)=>g.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[g.jsx("span",{className:"font-mono text-[#ddd]",children:P.display}),P.type&&g.jsx("span",{className:"rounded-full border border-[#2a2a2a] px-1.5 py-0.5 text-[10px] text-[#888]",children:P.type})]},T))})}),g.jsx(wn,{label:"Instruction",children:o?g.jsx("span",{className:"whitespace-pre-wrap",children:o}):g.jsx("span",{className:"text-[#666]",children:"None"})}),c&&g.jsx(wn,{label:"Pentest mode",children:c}),g.jsx(wn,{label:"Scope",children:N}),g.jsx(wn,{label:"Mode",children:y?"Non-interactive":"Interactive"}),x.length>0&&g.jsx(wn,{label:"Local sources",children:g.jsx("div",{className:"space-y-0.5 font-mono text-[#ddd]",children:x.map((P,T)=>g.jsx("div",{children:P},T))})}),_&&g.jsx(wn,{label:"Status",children:_})]})]}),g.jsxs("section",{children:[g.jsx("h3",{className:"mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]",children:"Usage & cost"}),w?g.jsxs("dl",{className:"space-y-2.5 tabular-nums",children:[g.jsx(wn,{label:"Model",children:E.length?E.join(", "):"n/a"}),z&&g.jsx(wn,{label:"Provider",children:g.jsx("span",{className:"inline-flex items-center gap-1.5",children:g.jsx("span",{className:"rounded-full border border-[#22c55e]/40 bg-[#22c55e]/10 px-2 py-0.5 text-[11px] text-[#22c55e]",children:"ChatGPT subscription"})})}),g.jsx(wn,{label:"Run time",children:nH(t)}),M!=null&&g.jsx(wn,{label:"Requests",children:Ds(M)}),I!=null&&g.jsxs(wn,{label:"Input tokens",children:[Ds(I),R!=null&&V(R,"cached")]}),U!=null&&g.jsxs(wn,{label:"Output tokens",children:[Ds(U),B!=null&&V(B,"reasoning")]}),Z!=null&&g.jsx(wn,{label:"Total tokens",children:Ds(Z)}),z?g.jsxs(wn,{label:"Cost",children:[g.jsx("span",{className:"text-[#22c55e]",children:"$0.00"}),g.jsx("span",{className:"text-[#666]",children:" (subscription)"})]}):j!=null&&g.jsxs(wn,{label:"Cost",children:["$",j.toFixed(2)]}),k.length>0&&g.jsx(wn,{label:"Agents",children:Ds(k.length)})]}):g.jsx("p",{className:"text-sm text-[#666]",children:"Not available yet."})]})]})]})}const E_="strix_viewer_trust_dismissed";function iH({message:e}){const[t,r]=ee.useState(()=>{try{return localStorage.getItem(E_)==="1"}catch{return!1}});if(t)return null;const a=()=>{try{localStorage.setItem(E_,"1")}catch{}r(!0)};return g.jsx("div",{className:"fixed bottom-3 left-3 z-[60] max-w-xs rounded-lg bg-[#0a0a0a] p-3 shadow-2xl",style:{border:"1px solid #2a2a2a"},role:"status",children:g.jsxs("div",{className:"flex gap-2.5",children:[g.jsx(I_,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs leading-relaxed text-[#aaa]",children:e}),g.jsx("button",{onClick:a,"aria-label":"Dismiss",className:"-mr-0.5 -mt-0.5 flex-shrink-0 cursor-pointer rounded p-0.5 text-[#666] transition-colors hover:text-white",children:g.jsx(vp,{className:"h-3.5 w-3.5"})})]})})}const aH=5e3,N_={invalid_email:"That email doesn't look right.",invalid_message:"Please write a little more.",unavailable:"Couldn't send that just now. Try again."};function sH({defaultEmail:e,onExit:t}){const[r,a]=ee.useState(""),[s,o]=ee.useState(e??""),[c,d]=ee.useState("form"),[h,f]=ee.useState(null),m=r.trim().length>0&&s.trim().length>0&&c!=="sending",p=async()=>{if(!m)return;d("sending"),f(null);const y=await NU(r.trim(),s.trim());if(y.ok){d("sent");return}d("form"),f(N_[y.error]??N_.unavailable)};return g.jsxs("div",{className:"mx-auto max-w-xl space-y-4",children:[g.jsxs("button",{onClick:()=>t("overview"),className:"cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white",children:[g.jsx(xp,{className:"h-4 w-4"}),"Back to results"]}),g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(pS,{className:"h-5 w-5 text-[#888]","aria-hidden":"true"}),g.jsx("h1",{className:"text-2xl font-semibold text-white",children:"Feedback & support"})]}),g.jsx("div",{className:"w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6",style:{border:"1px solid #2a2a2a"},children:c==="sent"?g.jsxs("div",{className:"flex items-start gap-3",children:[g.jsx(j_,{className:"mt-0.5 h-5 w-5 flex-shrink-0 text-emerald-400","aria-hidden":"true"}),g.jsxs("div",{className:"min-w-0",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Thanks, we got it."}),g.jsx("p",{className:"mt-1 text-xs text-[#888]",children:"We read every message. If it needs a reply, we'll reach out to the email you gave."}),g.jsx("button",{onClick:()=>{a(""),d("form")},className:"mt-4 cursor-pointer text-xs text-[#888] transition-colors hover:text-white",children:"Send more feedback"})]})]}):g.jsxs(g.Fragment,{children:[g.jsx("p",{className:"mb-4 text-xs text-[#666]",children:"Bugs, feature requests, or anything else. Tell us what's on your mind."}),h&&g.jsxs("div",{className:"mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2",children:[g.jsx(Hu,{className:"mt-0.5 h-4 w-4 flex-shrink-0 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-xs text-red-300",children:h})]}),g.jsxs("label",{className:"block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your feedback"}),g.jsx("textarea",{autoFocus:!0,value:r,maxLength:aH,onChange:y=>a(y.target.value),rows:5,placeholder:"What's working, what's not, what you'd love to see…",className:"w-full resize-y rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"})]}),g.jsxs("label",{className:"mt-4 block",children:[g.jsx("span",{className:"mb-1.5 block text-xs text-[#888]",children:"Your work email"}),g.jsx("input",{type:"email",value:s,onChange:y=>o(y.target.value),placeholder:"you@company.com",className:"w-full rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"})]}),g.jsx("button",{onClick:()=>void p(),disabled:!m,className:"mt-4 flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60",children:c==="sending"?"Sending…":"Send feedback"})]})})]})}function lH({text:e,children:t,className:r=""}){const[a,s]=ee.useState(!1);return g.jsxs("span",{className:`relative inline-flex ${r}`,onMouseEnter:()=>s(!0),onMouseLeave:()=>s(!1),onFocus:()=>s(!0),onBlur:()=>s(!1),children:[t,a&&g.jsx("span",{role:"tooltip",className:"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-[240px] -translate-x-1/2 rounded-md px-2.5 py-1.5 text-xs text-[#ddd] shadow-lg",style:{border:"1px solid #2a2a2a",background:"#0a0a0a"},children:e})]})}function gS({label:e,desc:t,slug:r,icon:a,surface:s}){return g.jsx(lH,{text:t,children:g.jsxs("a",{href:ha($u,r),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr(r,s),className:"group inline-flex items-center gap-2 rounded-lg border border-[#222] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-sm text-[#aaa] transition-colors hover:border-[#444] hover:text-white",children:[g.jsx(a,{className:"h-4 w-4 text-[#888] transition-colors group-hover:text-white","aria-hidden":"true"}),g.jsx("span",{children:e})]})})}const oH="Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix.",S_=["critical","high","medium","low"],cH=500;function uH(){const[e,t]=ee.useState(null),[r,a]=ee.useState(null),[s,o]=ee.useState(null),[c,d]=ee.useState(null),[h,f]=ee.useState("overview"),[m,p]=ee.useState(null),[y,x]=ee.useState(null),[_,N]=ee.useState("report"),[S,w]=ee.useState(!1),[k,E]=ee.useState(!1),M=ee.useCallback(async()=>{try{p(await SU())}catch{}},[]),I=ee.useCallback(async()=>{try{x(await _U())}catch{}},[]);ee.useEffect(()=>{M(),I(),wU().then(C=>E(C.can_steer)).catch(()=>{})},[M,I]);const R=ee.useRef(!1);ee.useEffect(()=>{let C=!1,D;R.current=!1;const Y=()=>{D=setTimeout(L,cH)},L=async()=>{if(!C)try{const{summary:G,raw:q,finished:Q}=await lS(e);if(C)return;if(Q&&!R.current){R.current=!0;const te=await h_(e);C||a(te);return}const[J,W]=await Promise.all([cS(e).catch(()=>({agents:[],events:[]})),oS(G.runId,e).catch(()=>[])]);if(C)return;a(te=>({summary:G,raw:q,finished:Q,transcript:J,vulnerabilities:W,reportMarkdown:(te==null?void 0:te.reportMarkdown)??null})),Y()}catch(G){if(C)return;o(G instanceof Error?G.message:"Could not load run data."),Y()}};return(async()=>{try{const G=await h_(e);if(C)return;a(G),G.finished?R.current=!0:Y()}catch(G){if(C)return;o(G instanceof Error?G.message:"Could not load run data."),Y()}})(),()=>{C=!0,D&&clearTimeout(D)}},[e]);const U=ee.useMemo(()=>r?yU(r.vulnerabilities):null,[r]),B=(r==null?void 0:r.vulnerabilities.find(C=>C.id===c))??null,Z=(r==null?void 0:r.transcript.agents.length)??0,j=(m==null?void 0:m.verified)===!0,z=ee.useRef(!1);ee.useEffect(()=>{z.current=!1},[e]),ee.useEffect(()=>{z.current||!r||(r.finished?(z.current=!0,f("overview")):Z>0&&(z.current=!0,f("agents")))},[r,Z]);const V=ee.useCallback(C=>{z.current=!0,f(C)},[]),P=ee.useCallback(C=>{t(C),d(null),a(null),o(null),z.current=!1},[]),T=ee.useCallback((C,D)=>{jr("email_report",D),N("report"),w(C),V("email")},[V]),$=ee.useCallback(()=>T(!1,"sidebar"),[T]),O=ee.useCallback(()=>T(!0,"overview"),[T]),H=ee.useCallback(()=>{I(),V("history")},[I,V]),X=ee.useCallback(async()=>{await M(),await I()},[M,I]),K=ee.useCallback(async()=>{await kU(),await M(),await I()},[M,I]);return g.jsxs("div",{className:"min-h-screen bg-black text-white flex",children:[g.jsx(FU,{view:h,onSelectView:C=>{d(null),C==="history"?H():V(C)},issuesCount:(r==null?void 0:r.vulnerabilities.length)??0,agentCount:Z,runCount:(y==null?void 0:y.count)??0,finished:(r==null?void 0:r.finished)??!1,verified:j,email:(m==null?void 0:m.email)??null,onOpenEmail:$,onOpenHistory:H,onForget:()=>void K()}),g.jsxs("div",{className:"flex-1 min-w-0",children:[g.jsx("div",{className:"border-b border-[#222]",children:g.jsxs("div",{className:"max-w-[88rem] mx-auto px-3 sm:px-6 py-4 flex items-center gap-1.5",children:[g.jsxs("a",{href:ha("https://app.strix.ai","logo"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("logo","topbar"),className:"flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100 lg:hidden",title:"Open Strix Cloud",children:[g.jsx("img",{src:"./logo.png",alt:"Strix",className:"w-10 h-8 object-cover"}),g.jsx("div",{className:"text-base text-white font-medium tracking-tight",children:"Strix"})]}),r&&g.jsx(fH,{finished:r.finished}),g.jsxs("div",{className:"ml-auto flex items-center gap-3",children:[j&&y&&!y.locked&&y.runs.length>0&&g.jsx(dH,{runs:y,activeRun:e,launchedName:bo((r==null?void 0:r.summary.targets[0])??null,(r==null?void 0:r.summary.runName)??(r==null?void 0:r.summary.runId)??"Current run"),onSelect:P}),g.jsxs("a",{href:ha($u,"run_in_cloud"),target:"_blank",rel:"noopener noreferrer",onClick:()=>jr("run_in_cloud","topbar"),className:"inline-flex items-center gap-1 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90",children:["Run in the cloud",g.jsx(T_,{className:"w-3 h-3","aria-hidden":"true"})]})]})]})}),g.jsxs("div",{className:"max-w-[88rem] mx-auto px-3 sm:px-6 py-8 sm:py-12 space-y-6",children:[s&&!r&&h!=="history"&&h!=="email"&&g.jsxs("div",{className:"rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5",children:[g.jsx(Hu,{className:"w-5 h-5 flex-shrink-0 mt-0.5 text-red-400","aria-hidden":"true"}),g.jsx("p",{className:"text-sm text-red-300",children:s})]}),g.jsx("div",{className:"animate-page-in space-y-6",children:h==="email"?g.jsx(eH,{activeRun:e,auth:m,purpose:_,skipDisclosure:S,onAuthChanged:()=>{M(),I()},onExit:C=>f(C==="history"?"history":"overview")}):h==="feedback"?g.jsx(sH,{defaultEmail:(m==null?void 0:m.email)??null,onExit:C=>f(C)}):h==="history"?g.jsxs("div",{className:"space-y-4",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(Vs,{className:"w-5 h-5 text-[#888]","aria-hidden":"true"}),g.jsx("h1",{className:"text-2xl font-semibold text-white",children:"Past runs"})]}),g.jsx(QU,{runs:y,activeRun:e,onSelectRun:P,onVerified:()=>void X()})]}):!r&&!s?g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-10 text-center",children:[g.jsx("div",{className:"w-6 h-6 mx-auto mb-3 rounded-full border-2 border-[#333] border-t-white animate-spin"}),g.jsx("p",{className:"text-sm text-[#888]",children:"Loading run data…"})]}):r&&U?g.jsxs(g.Fragment,{children:[g.jsx(mH,{summary:r.summary}),g.jsxs("div",{className:"flex gap-5 border-b border-[#2a2a2a] lg:hidden",children:[g.jsx(Bm,{active:h==="overview",onClick:()=>V("overview"),children:"Pentest Overview"}),g.jsxs(Bm,{active:h==="issues",onClick:()=>V("issues"),children:["Issues",r.vulnerabilities.length>0?` (${r.vulnerabilities.length})`:""]}),Z>0&&g.jsxs(Bm,{active:h==="agents",onClick:()=>V("agents"),children:["Agents (",Z,")"]})]}),h==="overview"?g.jsx(yH,{summary:r.summary,counts:U,total:r.vulnerabilities.length,reportMarkdown:r.reportMarkdown,raw:r.raw,finished:r.finished,onOpenEmail:O}):h==="agents"&&Z>0?g.jsx(vH,{run:r,canSteer:k}):B?g.jsxs("div",{className:"space-y-4",children:[g.jsxs("button",{onClick:()=>d(null),className:"cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] hover:text-white transition-colors",children:[g.jsx(xp,{className:"w-4 h-4"})," Back to all findings"]}),g.jsx(tD,{vulnerability:B})]}):g.jsx(pH,{vulnerabilities:r.vulnerabilities,finished:r.finished,onSelect:C=>d(C)})]}):null},`${e??"launched"}:${h}:${c??""}`)]})]}),g.jsx(iH,{message:oH})]})}function dH({runs:e,activeRun:t,launchedName:r,onSelect:a}){const[s,o]=ee.useState(!1),c=e.runs.find(h=>h.name===t),d=c?bo(c.target,c.name):r;return g.jsxs("div",{className:"relative",children:[g.jsxs("button",{onClick:()=>o(h=>!h),onBlur:()=>setTimeout(()=>o(!1),150),"aria-label":"Switch pentest",className:"flex items-center gap-2 rounded-lg border border-[#3a3a3a] bg-[rgba(255,255,255,0.05)] px-3 py-2 text-sm text-white transition-colors hover:border-[#555] hover:bg-[rgba(255,255,255,0.09)]",children:[g.jsx(Vs,{className:"h-4 w-4 flex-shrink-0 text-[#888]","aria-hidden":"true"}),g.jsx("span",{className:"flex-shrink-0 text-[#888]",children:"Pentest"}),g.jsx("span",{className:"max-w-[260px] truncate font-medium",children:d}),g.jsx(ho,{className:"h-4 w-4 flex-shrink-0 text-[#aaa]","aria-hidden":"true"})]}),s&&g.jsxs("div",{className:"absolute right-0 z-50 mt-2 max-h-96 w-96 overflow-y-auto rounded-xl py-1.5 shadow-2xl",style:{border:"1px solid #3a3a3a",background:"#0a0a0a"},children:[g.jsx("div",{className:"border-b border-[#222] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[#666]",children:"Switch pentest"}),e.runs.map(h=>{const f=h.name===t;return g.jsxs("button",{onMouseDown:()=>a(h.name),className:`flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors hover:bg-[rgba(255,255,255,0.06)] ${f?"bg-[rgba(255,255,255,0.04)] text-white":"text-[#aaa]"}`,children:[g.jsxs("span",{className:"min-w-0 flex-1",children:[g.jsx("span",{className:"block truncate font-medium",children:bo(h.target,h.name)}),h.target&&g.jsx("span",{className:"block truncate font-mono text-xs text-[#666]",children:h.target})]}),f&&g.jsx("span",{className:"h-2 w-2 flex-shrink-0 rounded-full bg-emerald-400"})]},h.name)})]})]})}function fH({finished:e}){return e?g.jsxs("span",{className:"ml-3 inline-flex items-center gap-1.5 text-xs text-[#888]",children:[g.jsx("span",{className:"w-1.5 h-1.5 rounded-full bg-[#555]"}),"Complete"]}):g.jsxs("span",{className:"ml-3 inline-flex items-center gap-1.5 text-xs text-emerald-400",children:[g.jsxs("span",{className:"relative flex h-1.5 w-1.5",children:[g.jsx("span",{className:"absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping"}),g.jsx("span",{className:"relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400"})]}),"Live"]})}function hH(e){if(e==null)return null;if(e<60)return`${e}s`;const t=Math.floor(e/60);return t<60?`${t}m`:`${Math.floor(t/60)}h ${t%60}m`}function mH({summary:e}){const t=hH(e.durationSeconds);return g.jsxs("div",{children:[g.jsx("h1",{className:"text-2xl font-semibold text-white",children:bo(e.targets[0]??null,e.runName??e.runId??"Pentest results")}),g.jsxs("div",{className:"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#888]",children:[e.targets.length>0&&g.jsx("span",{className:"font-mono text-[#aaa]",children:e.targets.join(", ")}),e.scanMode&&g.jsx(Im,{label:e.scanMode}),t&&g.jsx(Im,{label:t}),e.status&&g.jsx(Im,{label:e.status})]})]})}function Im({label:e}){return g.jsxs(g.Fragment,{children:[g.jsx("span",{className:"text-[#333]",children:"Β·"}),g.jsx("span",{className:"capitalize",children:e})]})}function pH({vulnerabilities:e,finished:t,onSelect:r}){const a=[...e].sort((s,o)=>S_.indexOf(s.severity)-S_.indexOf(o.severity));return a.length===0?g.jsxs("div",{className:"space-y-4",children:[g.jsx("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]",children:t?"No findings in this run.":"No findings yet. The pentest is still running…"}),t&&g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsx("p",{className:"text-sm font-medium text-white",children:"Stay ahead of new exposures"}),g.jsx("p",{className:"mt-0.5 mb-3 text-xs text-[#666]",children:"Attack surface monitoring catches new exposures for your org over time."}),g.jsx(gS,{label:"Attack surface monitoring",desc:"Continuous coverage for your whole org.",slug:"asm",surface:"empty_state",icon:XC})]})]}):g.jsx("div",{className:"space-y-2",children:a.map(s=>g.jsxs("button",{onClick:()=>r(s.id),className:"animate-card-in cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3",children:[g.jsx("span",{className:`w-2.5 h-2.5 rounded-full flex-shrink-0 ${_p(s.severity)}`,"aria-hidden":"true"}),g.jsxs("span",{className:"flex-1 min-w-0",children:[g.jsx("span",{className:"block text-sm font-medium text-white truncate",children:s.title}),s.target&&g.jsx("span",{className:"block text-xs text-[#666] font-mono truncate",children:s.target})]}),g.jsx("span",{className:`text-xs font-semibold px-2 py-0.5 rounded-full border capitalize ${U_[s.severity]}`,children:s.severity})]},s.id))})}function gH(e){return e.replace(/^\s*#{1,6}[ \t]+.*(?:\r?\n)+/,"").trimStart()}function bH(e){const t=[];let r=null;for(const a of e.split(` `)){const s=a.match(/^#{1,6}\s+(.*)$/);if(s){const o=s[1].trim().toLowerCase();if(o===r)continue;r=o}else a.trim()!==""&&(r=null);t.push(a)}return t.join(` -`)}function gH({onOpenEmail:e}){return g.jsx("button",{onClick:e,className:"group w-full cursor-pointer rounded-xl border border-emerald-500/25 bg-emerald-500/[0.06] p-4 text-left transition-colors hover:border-emerald-500/40",children:g.jsxs("div",{className:"flex items-center gap-3",children:[g.jsx("div",{className:"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg",style:{border:"1px solid rgba(16,185,129,0.3)",background:"rgba(16,185,129,0.08)"},children:g.jsx(yp,{className:"h-4 w-4 text-emerald-400","aria-hidden":"true"})}),g.jsxs("div",{className:"min-w-0 flex-1",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Email an encrypted PDF report of this run"}),g.jsx("p",{className:"mt-0.5 text-xs text-[#888]",children:"Encrypted with a key only you can see, email verified with a one-time code before sending."})]}),g.jsx("span",{className:"flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90",children:"Export report to PDF"})]})})}function bH({summary:e,counts:t,total:r,reportMarkdown:a,raw:s,finished:o,onOpenEmail:c}){const d=[["Executive Summary",e.executiveSummary],["Technical Analysis",e.technicalAnalysis],["Methodology",e.methodology],["Recommendations",e.recommendations]].filter(([,h])=>!!h).map(([h,f])=>({title:h,content:mH(f)}));return g.jsxs("div",{className:"space-y-6",children:[g.jsx("div",{className:"animate-card-in",children:g.jsx(tH,{raw:s,durationSeconds:e.durationSeconds})}),r>0&&g.jsx("div",{className:"animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:g.jsx(nD,{findings:{total:r,...t}})}),o&&g.jsx("div",{className:"animate-card-in",children:g.jsx(gH,{onOpenEmail:c})}),d.length>0?g.jsx("div",{className:"animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8",children:d.map(h=>g.jsx(oa,{title:h.title,content:h.content},h.title))}):a?g.jsx("div",{className:"animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:g.jsx(oa,{content:pH(a)})}):r===0&&g.jsx("p",{className:"text-sm text-[#888]",children:"No summary available for this run yet."})]})}function Bm({active:e,onClick:t,children:r}){return g.jsxs("button",{onClick:t,className:`cursor-pointer relative pb-2.5 text-sm font-semibold transition-colors ${e?"text-white":"text-[#666] hover:text-white"}`,children:[r,e&&g.jsx("span",{className:"absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full"})]})}function xH({run:e,canSteer:t}){const{agents:r,events:a}=e.transcript,s=ee.useMemo(()=>lU(r,a),[r,a]),[o,c]=ee.useState(null),d=o?r.find(f=>f.id===o)??null:null,h=t&&!e.finished;return g.jsxs("div",{className:"space-y-5",children:[g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(Ao,{className:"w-4 h-4 text-[#888]","aria-hidden":"true"}),g.jsx("h2",{className:"text-sm font-semibold text-white",children:"Agent graph"}),g.jsxs("span",{className:"text-xs text-[#666]",children:[r.length," agent",r.length===1?"":"s"]})]}),g.jsx("p",{className:"mt-1 mb-4 text-xs text-[#666]",children:"Click an agent to open its full transcript."}),g.jsx("div",{className:"h-[480px] rounded-lg border border-[#1a1a1a] overflow-hidden",children:g.jsx(GB,{agents:s,selectedAgentId:o,onSelectAgent:f=>c(f),eventsLoaded:!0,eventsEmpty:s.size===0,scanCompleted:e.finished})})]}),h&&g.jsx(fS,{agents:r}),g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Run this pentest with more depth"}),g.jsx("p",{className:"mt-0.5 text-xs text-[#666]",children:"Re-run this pentest on managed infra in the cloud."}),g.jsx("div",{className:"mt-3 flex flex-wrap gap-2.5",children:g.jsx(gS,{label:"Re-run in Strix Pro with more depth",desc:"Run this pentest on managed infra with more depth.",slug:"live_scan",surface:"agents",icon:WC})})]}),g.jsx(TU,{open:d!==null,agent:d,events:a,steerable:h,onClose:()=>c(null)})]})}Ck.createRoot(document.getElementById("root")).render(g.jsx(ee.StrictMode,{children:g.jsx(oH,{})})); +`)}function xH({onOpenEmail:e}){return g.jsx("button",{onClick:e,className:"group w-full cursor-pointer rounded-xl border border-emerald-500/25 bg-emerald-500/[0.06] p-4 text-left transition-colors hover:border-emerald-500/40",children:g.jsxs("div",{className:"flex items-center gap-3",children:[g.jsx("div",{className:"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg",style:{border:"1px solid rgba(16,185,129,0.3)",background:"rgba(16,185,129,0.08)"},children:g.jsx(yp,{className:"h-4 w-4 text-emerald-400","aria-hidden":"true"})}),g.jsxs("div",{className:"min-w-0 flex-1",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Email an encrypted PDF report of this run"}),g.jsx("p",{className:"mt-0.5 text-xs text-[#888]",children:"Encrypted with a key only you can see, email verified with a one-time code before sending."})]}),g.jsx("span",{className:"flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90",children:"Export report to PDF"})]})})}function yH({summary:e,counts:t,total:r,reportMarkdown:a,raw:s,finished:o,onOpenEmail:c}){const d=[["Executive Summary",e.executiveSummary],["Technical Analysis",e.technicalAnalysis],["Methodology",e.methodology],["Recommendations",e.recommendations]].filter(([,h])=>!!h).map(([h,f])=>({title:h,content:gH(f)}));return g.jsxs("div",{className:"space-y-6",children:[g.jsx("div",{className:"animate-card-in",children:g.jsx(rH,{raw:s,durationSeconds:e.durationSeconds})}),r>0&&g.jsx("div",{className:"animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:g.jsx(nD,{findings:{total:r,...t}})}),o&&g.jsx("div",{className:"animate-card-in",children:g.jsx(xH,{onOpenEmail:c})}),d.length>0?g.jsx("div",{className:"animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8",children:d.map(h=>g.jsx(oa,{title:h.title,content:h.content},h.title))}):a?g.jsx("div",{className:"animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:g.jsx(oa,{content:bH(a)})}):r===0&&g.jsx("p",{className:"text-sm text-[#888]",children:"No summary available for this run yet."})]})}function Bm({active:e,onClick:t,children:r}){return g.jsxs("button",{onClick:t,className:`cursor-pointer relative pb-2.5 text-sm font-semibold transition-colors ${e?"text-white":"text-[#666] hover:text-white"}`,children:[r,e&&g.jsx("span",{className:"absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full"})]})}function vH({run:e,canSteer:t}){const{agents:r,events:a}=e.transcript,s=ee.useMemo(()=>cU(r,a),[r,a]),[o,c]=ee.useState(null),d=o?r.find(f=>f.id===o)??null:null,h=t&&!e.finished;return g.jsxs("div",{className:"space-y-5",children:[g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(Ao,{className:"w-4 h-4 text-[#888]","aria-hidden":"true"}),g.jsx("h2",{className:"text-sm font-semibold text-white",children:"Agent graph"}),g.jsxs("span",{className:"text-xs text-[#666]",children:[r.length," agent",r.length===1?"":"s"]})]}),g.jsx("p",{className:"mt-1 mb-4 text-xs text-[#666]",children:"Click an agent to open its full transcript."}),g.jsx("div",{className:"h-[480px] rounded-lg border border-[#1a1a1a] overflow-hidden",children:g.jsx(GB,{agents:s,selectedAgentId:o,onSelectAgent:f=>c(f),eventsLoaded:!0,eventsEmpty:s.size===0,scanCompleted:e.finished})})]}),h&&g.jsx(fS,{agents:r}),g.jsxs("div",{className:"rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5",children:[g.jsx("p",{className:"text-sm font-semibold text-white",children:"Run this pentest with more depth"}),g.jsx("p",{className:"mt-0.5 text-xs text-[#666]",children:"Re-run this pentest on managed infra in the cloud."}),g.jsx("div",{className:"mt-3 flex flex-wrap gap-2.5",children:g.jsx(gS,{label:"Re-run in Strix Pro with more depth",desc:"Run this pentest on managed infra with more depth.",slug:"live_scan",surface:"agents",icon:WC})})]}),g.jsx(MU,{open:d!==null,agent:d,events:a,steerable:h,onClose:()=>c(null)})]})}Ck.createRoot(document.getElementById("root")).render(g.jsx(ee.StrictMode,{children:g.jsx(uH,{})})); diff --git a/strix/interface/viewer/static/assets/index-DKbLYAbP.css b/strix/interface/viewer/static/assets/index-DKbLYAbP.css new file mode 100644 index 00000000..13a934ac --- /dev/null +++ b/strix/interface/viewer/static/assets/index-DKbLYAbP.css @@ -0,0 +1,10 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: GitHub Dark + Description: Dark theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-dark + Current colors taken from GitHub's CSS +*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#79c0ff}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-comment,.hljs-code,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #555;--xy-background-pattern-lines-color-default: #333;--xy-background-pattern-cross-color-default: #333;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1;touch-action:none}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))}/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-amber-400:oklch(82.8% .189 84.429);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-sky-400:oklch(74.6% .16 232.661);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-neutral-200:oklch(92.2% 0 none);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-xl:36rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-geist-sans);--default-mono-font-family:var(--font-geist-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.inset-x-0{inset-inline:0}.top-0{top:0}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.right-0{right:0}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-full{bottom:100%}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-3{left:calc(var(--spacing) * 3)}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.-mx-2{margin-inline:calc(var(--spacing) * -2)}.mx-0{margin-inline:0}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.my-4{margin-block:calc(var(--spacing) * 4)}.-mt-0\.5{margin-top:calc(var(--spacing) * -.5)}.mt-0{margin-top:0}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-\[1px\]{margin-top:1px}.-mr-0\.5{margin-right:calc(var(--spacing) * -.5)}.mr-1{margin-right:var(--spacing)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.\!h-1\.5{height:calc(var(--spacing) * 1.5)!important}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-\[30px\]{height:30px}.h-\[60vh\]{height:60vh}.h-\[72px\]{height:72px}.h-\[480px\]{height:480px}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[160px\]{max-height:160px}.max-h-\[400px\]{max-height:400px}.max-h-\[1200px\]{max-height:1200px}.min-h-screen{min-height:100vh}.\!w-1\.5{width:calc(var(--spacing) * 1.5)!important}.w-0\.5{width:calc(var(--spacing) * .5)}.w-1{width:var(--spacing)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-28{width:calc(var(--spacing) * 28)}.w-96{width:calc(var(--spacing) * 96)}.w-\[1px\]{width:1px}.w-\[30px\]{width:30px}.w-\[180px\]{width:180px}.w-\[260px\]{width:260px}.w-\[calc\(100vw-4rem\)\]{width:calc(100vw - 4rem)}.w-full{width:100%}.w-max{width:max-content}.w-px{width:1px}.max-w-6xl{max-width:var(--container-6xl)}.max-w-\[75\%\]{max-width:75%}.max-w-\[80\%\]{max-width:80%}.max-w-\[88rem\]{max-width:88rem}.max-w-\[140px\]{max-width:140px}.max-w-\[240px\]{max-width:240px}.max-w-\[260px\]{max-width:260px}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-\[80px\]{min-width:80px}.min-w-\[112px\]{min-width:112px}.min-w-\[160px\]{min-width:160px}.flex-1{flex:1}.flex-none{flex:none}.flex-shrink-0,.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.origin-left{transform-origin:0}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-90{rotate:-90deg}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize-none{resize:none}.resize-y{resize:vertical}.scrollbar-thin{scrollbar-width:thin}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-\[7rem_1fr\]{grid-template-columns:7rem 1fr}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.place-content-center{place-content:center}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}.gap-px{gap:1px}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-6{row-gap:calc(var(--spacing) * 6)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-clip{overflow-x:clip}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.\!border-0{border-style:var(--tw-border-style)!important;border-width:0!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\!border-none{--tw-border-style:none!important;border-style:none!important}.\!border-\[\#222\]{border-color:#222!important}.border-\[\#1a1a1a\]{border-color:#1a1a1a}.border-\[\#2a2a2a\]{border-color:#2a2a2a}.border-\[\#3a3a3a\]{border-color:#3a3a3a}.border-\[\#22c55e\]\/40{border-color:#22c55e66}.border-\[\#222\]{border-color:#222}.border-\[\#333\]{border-color:#333}.border-\[\#444\]{border-color:#444}.border-\[\#191919\]{border-color:#191919}.border-\[rgba\(255\,255\,255\,0\.08\)\]{border-color:#ffffff14}.border-blue-500\/20{border-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/20{border-color:color-mix(in oklab,var(--color-blue-500) 20%,transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/30{border-color:color-mix(in oklab,var(--color-blue-500) 30%,transparent)}}.border-blue-500\/40{border-color:#3080ff66}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/40{border-color:color-mix(in oklab,var(--color-blue-500) 40%,transparent)}}.border-emerald-500\/20{border-color:#00bb7f33}@supports (color:color-mix(in lab,red,red)){.border-emerald-500\/20{border-color:color-mix(in oklab,var(--color-emerald-500) 20%,transparent)}}.border-emerald-500\/25{border-color:#00bb7f40}@supports (color:color-mix(in lab,red,red)){.border-emerald-500\/25{border-color:color-mix(in oklab,var(--color-emerald-500) 25%,transparent)}}.border-emerald-500\/30{border-color:#00bb7f4d}@supports (color:color-mix(in lab,red,red)){.border-emerald-500\/30{border-color:color-mix(in oklab,var(--color-emerald-500) 30%,transparent)}}.border-gray-500\/20{border-color:#6a728233}@supports (color:color-mix(in lab,red,red)){.border-gray-500\/20{border-color:color-mix(in oklab,var(--color-gray-500) 20%,transparent)}}.border-orange-500\/20{border-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/20{border-color:color-mix(in oklab,var(--color-orange-500) 20%,transparent)}}.border-orange-500\/30{border-color:#fe6e004d}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/30{border-color:color-mix(in oklab,var(--color-orange-500) 30%,transparent)}}.border-purple-500\/20{border-color:#ac4bff33}@supports (color:color-mix(in lab,red,red)){.border-purple-500\/20{border-color:color-mix(in oklab,var(--color-purple-500) 20%,transparent)}}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.border-red-500\/20{border-color:color-mix(in oklab,var(--color-red-500) 20%,transparent)}}.border-red-500\/25{border-color:#fb2c3640}@supports (color:color-mix(in lab,red,red)){.border-red-500\/25{border-color:color-mix(in oklab,var(--color-red-500) 25%,transparent)}}.border-red-500\/30{border-color:#fb2c364d}@supports (color:color-mix(in lab,red,red)){.border-red-500\/30{border-color:color-mix(in oklab,var(--color-red-500) 30%,transparent)}}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.border-white\/10{border-color:color-mix(in oklab,var(--color-white) 10%,transparent)}}.border-white\/30{border-color:#ffffff4d}@supports (color:color-mix(in lab,red,red)){.border-white\/30{border-color:color-mix(in oklab,var(--color-white) 30%,transparent)}}.border-white\/\[0\.06\]{border-color:#ffffff0f}@supports (color:color-mix(in lab,red,red)){.border-white\/\[0\.06\]{border-color:color-mix(in oklab,var(--color-white) 6%,transparent)}}.border-white\/\[0\.08\]{border-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.border-white\/\[0\.08\]{border-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.border-white\/\[0\.18\]{border-color:#ffffff2e}@supports (color:color-mix(in lab,red,red)){.border-white\/\[0\.18\]{border-color:color-mix(in oklab,var(--color-white) 18%,transparent)}}.border-yellow-500\/20{border-color:#edb20033}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\/20{border-color:color-mix(in oklab,var(--color-yellow-500) 20%,transparent)}}.border-yellow-500\/25{border-color:#edb20040}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\/25{border-color:color-mix(in oklab,var(--color-yellow-500) 25%,transparent)}}.border-yellow-500\/30{border-color:#edb2004d}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\/30{border-color:color-mix(in oklab,var(--color-yellow-500) 30%,transparent)}}.border-t-white{border-top-color:var(--color-white)}.\!bg-\[\#0a0a0a\]{background-color:#0a0a0a!important}.\!bg-\[\#444\]{background-color:#444!important}.\!bg-transparent{background-color:#0000!important}.bg-\[\#0a0a0a\]{background-color:#0a0a0a}.bg-\[\#1a1a1a\]{background-color:#1a1a1a}.bg-\[\#1e1e1e\]{background-color:#1e1e1e}.bg-\[\#2a2a2a\]{background-color:#2a2a2a}.bg-\[\#22c55e\]\/10{background-color:#22c55e1a}.bg-\[\#111\]{background-color:#111}.bg-\[\#222\]{background-color:#222}.bg-\[\#555\]{background-color:#555}.bg-\[\#888\]{background-color:#888}.bg-\[\#050505\]{background-color:#050505}.bg-\[\#252525\]{background-color:#252525}.bg-\[rgba\(255\,255\,255\,0\.02\)\]{background-color:#ffffff05}.bg-\[rgba\(255\,255\,255\,0\.3\)\]{background-color:#ffffff4d}.bg-\[rgba\(255\,255\,255\,0\.04\)\]{background-color:#ffffff0a}.bg-\[rgba\(255\,255\,255\,0\.05\)\]{background-color:#ffffff0d}.bg-\[rgba\(255\,255\,255\,0\.08\)\]{background-color:#ffffff14}.bg-\[rgba\(255\,255\,255\,0\.12\)\]{background-color:#ffffff1f}.bg-black{background-color:var(--color-black)}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab,red,red)){.bg-black\/80{background-color:color-mix(in oklab,var(--color-black) 80%,transparent)}}.bg-blue-400{background-color:var(--color-blue-400)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/10{background-color:color-mix(in oklab,var(--color-blue-500) 10%,transparent)}}.bg-blue-500\/20{background-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/20{background-color:color-mix(in oklab,var(--color-blue-500) 20%,transparent)}}.bg-blue-500\/\[0\.12\]{background-color:#3080ff1f}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/\[0\.12\]{background-color:color-mix(in oklab,var(--color-blue-500) 12%,transparent)}}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/5{background-color:#00bb7f0d}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/5{background-color:color-mix(in oklab,var(--color-emerald-500) 5%,transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/10{background-color:color-mix(in oklab,var(--color-emerald-500) 10%,transparent)}}.bg-emerald-500\/\[0\.06\]{background-color:#00bb7f0f}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/\[0\.06\]{background-color:color-mix(in oklab,var(--color-emerald-500) 6%,transparent)}}.bg-gray-500{background-color:var(--color-gray-500)}.bg-gray-500\/10{background-color:#6a72821a}@supports (color:color-mix(in lab,red,red)){.bg-gray-500\/10{background-color:color-mix(in oklab,var(--color-gray-500) 10%,transparent)}}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-500\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/10{background-color:color-mix(in oklab,var(--color-orange-500) 10%,transparent)}}.bg-orange-500\/20{background-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/20{background-color:color-mix(in oklab,var(--color-orange-500) 20%,transparent)}}.bg-purple-500{background-color:var(--color-purple-500)}.bg-purple-500\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\/10{background-color:color-mix(in oklab,var(--color-purple-500) 10%,transparent)}}.bg-purple-500\/\[0\.08\]{background-color:#ac4bff14}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\/\[0\.08\]{background-color:color-mix(in oklab,var(--color-purple-500) 8%,transparent)}}.bg-red-400{background-color:var(--color-red-400)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/5{background-color:#fb2c360d}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/5{background-color:color-mix(in oklab,var(--color-red-500) 5%,transparent)}}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500) 10%,transparent)}}.bg-red-500\/15{background-color:#fb2c3626}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/15{background-color:color-mix(in oklab,var(--color-red-500) 15%,transparent)}}.bg-red-500\/20{background-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/20{background-color:color-mix(in oklab,var(--color-red-500) 20%,transparent)}}.bg-red-500\/\[0\.12\]{background-color:#fb2c361f}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/\[0\.12\]{background-color:color-mix(in oklab,var(--color-red-500) 12%,transparent)}}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/8{background-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.bg-white\/8{background-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab,red,red)){.bg-white\/60{background-color:color-mix(in oklab,var(--color-white) 60%,transparent)}}.bg-white\/\[0\.03\]{background-color:#ffffff08}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.03\]{background-color:color-mix(in oklab,var(--color-white) 3%,transparent)}}.bg-white\/\[0\.08\]{background-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.08\]{background-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.bg-white\/\[0\.015\]{background-color:#ffffff04}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.015\]{background-color:color-mix(in oklab,var(--color-white) 1.5%,transparent)}}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-yellow-500\/10{background-color:#edb2001a}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/10{background-color:color-mix(in oklab,var(--color-yellow-500) 10%,transparent)}}.bg-yellow-500\/15{background-color:#edb20026}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/15{background-color:color-mix(in oklab,var(--color-yellow-500) 15%,transparent)}}.bg-yellow-500\/20{background-color:#edb20033}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\/20{background-color:color-mix(in oklab,var(--color-yellow-500) 20%,transparent)}}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-emerald-400{--tw-gradient-from:var(--color-emerald-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-cyan-500{--tw-gradient-to:var(--color-cyan-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-0{padding:0}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-10{padding:calc(var(--spacing) * 10)}.px-0{padding-inline:0}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-8{padding-block:calc(var(--spacing) * 8)}.pt-1{padding-top:var(--spacing)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-\[5px\]{padding-top:5px}.pr-1{padding-right:var(--spacing)}.pr-1\.5{padding-right:calc(var(--spacing) * 1.5)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pb-2\.5{padding-bottom:calc(var(--spacing) * 2.5)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.pl-1\.5{padding-left:calc(var(--spacing) * 1.5)}.pl-2\.5{padding-left:calc(var(--spacing) * 2.5)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:var(--font-geist-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[15px\]{font-size:15px}.text-\[16px\]{font-size:16px}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-\[22px\]{--tw-leading:22px;line-height:22px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.4em\]{--tw-tracking:.4em;letter-spacing:.4em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#22c55e\]{color:#22c55e}.text-\[\#333\]{color:#333}.text-\[\#444\]{color:#444}.text-\[\#555\]{color:#555}.text-\[\#666\]{color:#666}.text-\[\#777\]{color:#777}.text-\[\#888\]{color:#888}.text-\[\#999\]{color:#999}.text-\[\#aaa\]{color:#aaa}.text-\[\#bbb\]{color:#bbb}.text-\[\#ddd\]{color:#ddd}.text-\[\#e5e5e5\]{color:#e5e5e5}.text-\[\#ededed\]{color:#ededed}.text-amber-400{color:var(--color-amber-400)}.text-amber-400\/80{color:#fcbb00cc}@supports (color:color-mix(in lab,red,red)){.text-amber-400\/80{color:color-mix(in oklab,var(--color-amber-400) 80%,transparent)}}.text-black{color:var(--color-black)}.text-blue-400{color:var(--color-blue-400)}.text-blue-400\/80{color:#54a2ffcc}@supports (color:color-mix(in lab,red,red)){.text-blue-400\/80{color:color-mix(in oklab,var(--color-blue-400) 80%,transparent)}}.text-blue-500{color:var(--color-blue-500)}.text-cyan-400{color:var(--color-cyan-400)}.text-cyan-400\/80{color:#00d2efcc}@supports (color:color-mix(in lab,red,red)){.text-cyan-400\/80{color:color-mix(in oklab,var(--color-cyan-400) 80%,transparent)}}.text-emerald-200{color:var(--color-emerald-200)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-400\/30{color:#00d2944d}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/30{color:color-mix(in oklab,var(--color-emerald-400) 30%,transparent)}}.text-emerald-400\/60{color:#00d29499}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/60{color:color-mix(in oklab,var(--color-emerald-400) 60%,transparent)}}.text-emerald-400\/70{color:#00d294b3}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/70{color:color-mix(in oklab,var(--color-emerald-400) 70%,transparent)}}.text-emerald-400\/80{color:#00d294cc}@supports (color:color-mix(in lab,red,red)){.text-emerald-400\/80{color:color-mix(in oklab,var(--color-emerald-400) 80%,transparent)}}.text-gray-400{color:var(--color-gray-400)}.text-orange-400{color:var(--color-orange-400)}.text-orange-400\/60{color:#ff8b1a99}@supports (color:color-mix(in lab,red,red)){.text-orange-400\/60{color:color-mix(in oklab,var(--color-orange-400) 60%,transparent)}}.text-orange-400\/80{color:#ff8b1acc}@supports (color:color-mix(in lab,red,red)){.text-orange-400\/80{color:color-mix(in oklab,var(--color-orange-400) 80%,transparent)}}.text-orange-500{color:var(--color-orange-500)}.text-purple-400{color:var(--color-purple-400)}.text-purple-400\/60{color:#c07eff99}@supports (color:color-mix(in lab,red,red)){.text-purple-400\/60{color:color-mix(in oklab,var(--color-purple-400) 60%,transparent)}}.text-purple-400\/70{color:#c07effb3}@supports (color:color-mix(in lab,red,red)){.text-purple-400\/70{color:color-mix(in oklab,var(--color-purple-400) 70%,transparent)}}.text-purple-400\/80{color:#c07effcc}@supports (color:color-mix(in lab,red,red)){.text-purple-400\/80{color:color-mix(in oklab,var(--color-purple-400) 80%,transparent)}}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-400\/30{color:#ff65684d}@supports (color:color-mix(in lab,red,red)){.text-red-400\/30{color:color-mix(in oklab,var(--color-red-400) 30%,transparent)}}.text-red-400\/50{color:#ff656880}@supports (color:color-mix(in lab,red,red)){.text-red-400\/50{color:color-mix(in oklab,var(--color-red-400) 50%,transparent)}}.text-red-400\/60{color:#ff656899}@supports (color:color-mix(in lab,red,red)){.text-red-400\/60{color:color-mix(in oklab,var(--color-red-400) 60%,transparent)}}.text-red-400\/70{color:#ff6568b3}@supports (color:color-mix(in lab,red,red)){.text-red-400\/70{color:color-mix(in oklab,var(--color-red-400) 70%,transparent)}}.text-red-400\/80{color:#ff6568cc}@supports (color:color-mix(in lab,red,red)){.text-red-400\/80{color:color-mix(in oklab,var(--color-red-400) 80%,transparent)}}.text-red-500{color:var(--color-red-500)}.text-sky-400{color:var(--color-sky-400)}.text-sky-400\/80{color:#00bcfecc}@supports (color:color-mix(in lab,red,red)){.text-sky-400\/80{color:color-mix(in oklab,var(--color-sky-400) 80%,transparent)}}.text-white{color:var(--color-white)}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab,red,red)){.text-white\/80{color:color-mix(in oklab,var(--color-white) 80%,transparent)}}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-400\/80{color:#fac800cc}@supports (color:color-mix(in lab,red,red)){.text-yellow-400\/80{color:color-mix(in oklab,var(--color-yellow-400) 80%,transparent)}}.text-yellow-500{color:var(--color-yellow-500)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.opacity-0{opacity:0}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.opacity-100{opacity:1}.\!shadow-none{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[font-variant-ligatures\:none\]{font-variant-ligatures:none}@media(hover:hover){.group-hover\:bg-\[rgba\(255\,255\,255\,0\.2\)\]:is(:where(.group):hover *){background-color:#fff3}.group-hover\:text-\[\#aaa\]:is(:where(.group):hover *){color:#aaa}.group-hover\:text-white:is(:where(.group):hover *){color:var(--color-white)}.group-hover\:opacity-90:is(:where(.group):hover *){opacity:.9}.group-hover\/code\:opacity-100:is(:where(.group\/code):hover *){opacity:1}}.placeholder\:text-\[\#444\]::placeholder{color:#444}@media(hover:hover){.hover\:border-\[\#333\]:hover{border-color:#333}.hover\:border-\[\#444\]:hover{border-color:#444}.hover\:border-\[\#555\]:hover{border-color:#555}.hover\:border-emerald-500\/40:hover{border-color:#00bb7f66}@supports (color:color-mix(in lab,red,red)){.hover\:border-emerald-500\/40:hover{border-color:color-mix(in oklab,var(--color-emerald-500) 40%,transparent)}}.hover\:border-white\/\[0\.12\]:hover{border-color:#ffffff1f}@supports (color:color-mix(in lab,red,red)){.hover\:border-white\/\[0\.12\]:hover{border-color:color-mix(in oklab,var(--color-white) 12%,transparent)}}.hover\:border-white\/\[0\.16\]:hover{border-color:#ffffff29}@supports (color:color-mix(in lab,red,red)){.hover\:border-white\/\[0\.16\]:hover{border-color:color-mix(in oklab,var(--color-white) 16%,transparent)}}.hover\:bg-\[\#1a1a1a\]:hover{background-color:#1a1a1a}.hover\:bg-\[\#2a2a2a\]:hover{background-color:#2a2a2a}.hover\:bg-\[rgba\(255\,255\,255\,0\.06\)\]:hover{background-color:#ffffff0f}.hover\:bg-\[rgba\(255\,255\,255\,0\.08\)\]:hover{background-color:#ffffff14}.hover\:bg-\[rgba\(255\,255\,255\,0\.09\)\]:hover{background-color:#ffffff17}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:bg-white\/\[0\.06\]:hover{background-color:#ffffff0f}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.06\]:hover{background-color:color-mix(in oklab,var(--color-white) 6%,transparent)}}.hover\:text-\[\#888\]:hover{color:#888}.hover\:text-\[\#aaa\]:hover{color:#aaa}.hover\:text-\[\#ccc\]:hover{color:#ccc}.hover\:text-\[\#ededed\]:hover{color:#ededed}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}}.focus\:border-\[\#444\]:focus{border-color:#444}.focus\:border-white\/50:focus{border-color:#ffffff80}@supports (color:color-mix(in lab,red,red)){.focus\:border-white\/50:focus{border-color:color-mix(in oklab,var(--color-white) 50%,transparent)}}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-white\/10:focus{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.focus\:ring-white\/10:focus{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-white\/20:focus-visible{--tw-ring-color:#fff3}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-white\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:opacity-60:disabled{opacity:.6}@media(min-width:40rem){.sm\:rounded-lg{border-radius:var(--radius-lg)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}}@media(min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-6{top:calc(var(--spacing) * 6)}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:max-h-\[calc\(100vh-3rem\)\]{max-height:calc(100vh - 3rem)}.lg\:grid-cols-\[1fr_340px\]{grid-template-columns:1fr 340px}.lg\:overflow-y-auto{overflow-y:auto}.lg\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.lg\:border-\[\#2a2a2a\]{border-color:#2a2a2a}.lg\:pl-6{padding-left:calc(var(--spacing) * 6)}}.\[\&_svg\]\:h-3\.5 svg{height:calc(var(--spacing) * 3.5)}.\[\&_svg\]\:w-3\.5 svg{width:calc(var(--spacing) * 3.5)}}:root{--font-geist-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;--font-geist-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}html,body{color:#fff;font-family:var(--font-geist-sans);background:#000}.scrollbar-thin{scrollbar-width:thin;scrollbar-color:#ffffff26 transparent}.scrollbar-thin::-webkit-scrollbar{width:6px;height:6px}.scrollbar-thin::-webkit-scrollbar-thumb{background:#ffffff26;border-radius:3px}.scrollbar-thin::-webkit-scrollbar-track{background:0 0}@keyframes page-in{0%{opacity:0;filter:blur(8px);transform:translateY(8px)}to{opacity:1;filter:blur();transform:translateY(0)}}.animate-page-in{animation:.15s ease-out page-in}@keyframes fade-in{0%{opacity:0}to{opacity:1}}.animate-fade-in{animation:.35s ease-out fade-in}@keyframes cardIn{0%{opacity:0;filter:blur(4px);transform:translateY(8px)scale(.97)}to{opacity:1;filter:blur();transform:translateY(0)scale(1)}}.animate-card-in{opacity:0;animation:.3s cubic-bezier(.16,1,.3,1) forwards cardIn}.animate-card-in:first-child{animation-delay:0s}.animate-card-in:nth-child(2){animation-delay:50ms}.animate-card-in:nth-child(3){animation-delay:.1s}.animate-card-in:nth-child(4){animation-delay:.15s}@keyframes shimmer{0%{transform:translate(-100%)}to{transform:translate(400%)}}.animate-shimmer{animation:2s infinite shimmer}@keyframes dialog-overlay-in{0%{opacity:0}to{opacity:1}}@keyframes dialog-overlay-out{0%{opacity:1}to{opacity:0}}@keyframes dialog-panel-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes dialog-panel-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.95)}}.dialog-overlay[data-state=open]{animation:.2s dialog-overlay-in}.dialog-overlay[data-state=closed]{animation:.2s forwards dialog-overlay-out}.dialog-panel[data-state=open]{animation:.2s dialog-panel-in}.dialog-panel[data-state=closed]{animation:.2s forwards dialog-panel-out}.agent-modal[data-state=open]{animation:.14s dialog-overlay-in}.agent-modal[data-state=closed]{animation:.14s forwards dialog-overlay-out}@keyframes tab-in{0%{opacity:0;filter:blur(4px);transform:translateY(6px)}to{opacity:1;filter:blur();transform:translateY(0)}}.animate-tab-in{animation:.2s ease-out tab-in}.prose-markdown{color:#999;word-wrap:break-word;overflow-wrap:break-word;font-size:14px;line-height:1.7}.prose-markdown p{margin-bottom:.75em}.prose-markdown p:last-child{margin-bottom:0}.prose-markdown strong{color:#ccc;font-weight:600}.prose-markdown em{font-style:italic}.prose-markdown code{color:#ccc;font-variant-ligatures:none;background:#0a0a0a;border:1px solid #111;border-radius:4px;padding:.15em .4em;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:.9em}.prose-markdown pre{font-variant-ligatures:none;background:0 0;border:none;border-radius:0;margin:0;padding:0}.prose-markdown pre code{color:inherit;background:0 0;border:none;padding:0;font-size:13px}.prose-markdown ul,.prose-markdown ol{margin-bottom:.75em;padding-left:1.5em}.prose-markdown ul{list-style-type:disc}.prose-markdown ol{list-style-type:decimal}.prose-markdown li{margin-bottom:.25em}.prose-markdown li>ul,.prose-markdown li>ol{margin-top:.25em;margin-bottom:.25em;padding-left:1.5em}.prose-markdown ol+ul{margin-top:-.5em;padding-left:3em}.prose-markdown h1,.prose-markdown h2,.prose-markdown h3,.prose-markdown h4,.prose-markdown h5,.prose-markdown h6{color:#ddd;margin-top:1em;margin-bottom:.5em;font-weight:600}.prose-markdown a{color:inherit;pointer-events:none;text-decoration:none}.prose-markdown blockquote{color:#777;border-left:3px solid #333;margin:.75em 0;padding-left:1em}.prose-markdown hr{border:none;border-top:1px solid #222;margin:1em 0}.prose-markdown>table{border-collapse:collapse;width:100%;margin:.75em 0}.prose-markdown>table th,.prose-markdown>table td{text-align:left;border:1px solid #333;padding:.4em .75em;font-size:13px}.prose-markdown>table th{color:#ccc;background:#1a1a1a;font-weight:600}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} diff --git a/strix/interface/viewer/static/index.html b/strix/interface/viewer/static/index.html index 1e46c38f..642ebfcf 100644 --- a/strix/interface/viewer/static/index.html +++ b/strix/interface/viewer/static/index.html @@ -6,8 +6,8 @@ Strix Results - - + +
diff --git a/strix/interface/viewer/transcript.py b/strix/interface/viewer/transcript.py index 0011315c..4c3cf045 100644 --- a/strix/interface/viewer/transcript.py +++ b/strix/interface/viewer/transcript.py @@ -7,6 +7,7 @@ import logging from typing import TYPE_CHECKING, Any from strix.core.paths import run_record_path +from strix.interface.tui.live_view import TuiLiveView if TYPE_CHECKING: @@ -41,12 +42,9 @@ def severity_counts(vulns: list[Any]) -> dict[str, int]: def build_run_state(run_dir: Path) -> dict[str, Any]: """Agent graph + full per-agent event/message stream. - Reuses the Textual-free ``TuiLiveView`` projection so the viewer and the TUI + Reuses the shared ``TuiLiveView`` projection so the viewer and the TUI share one parser for ``agents.json`` + ``agents.db`` and never drift. """ - # Imported lazily so importing strix.interface.viewer does not eagerly pull the TUI. - from strix.interface.tui.live_view import TuiLiveView - view = TuiLiveView() view.hydrate_from_run_dir(run_dir) return {"agents": list(view.agents.values()), "events": view.events} diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index b4e06389..4b61d735 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -36,8 +36,14 @@ _PROTECTED_METADATA_NAMES = (".git", ".agents", ".codex") def _host_identity_env() -> dict[str, str]: - if sys.platform != "linux": + # Read the platform through a local so it is not narrowed to whichever OS is + # type-checking: comparing sys.platform directly makes one of these branches + # statically dead, and which one flips between Linux and macOS. + platform_name: str = sys.platform + if platform_name != "linux": return {} + # Bind-mount ownership only needs mapping on Linux, where the container uid + # must match the host's. return {"STRIX_HOST_UID": str(os.getuid()), "STRIX_HOST_GID": str(os.getgid())} diff --git a/strix/telemetry/_common.py b/strix/telemetry/_common.py index ff53ceef..7923a506 100644 --- a/strix/telemetry/_common.py +++ b/strix/telemetry/_common.py @@ -13,6 +13,12 @@ logger = logging.getLogger(__name__) SESSION_ID: str = uuid4().hex[:16] +# (connect, read) seconds. Telemetry is a beacon, never something a user waits +# on, and these calls sit on the shutdown path: an endpoint that is blackholed by +# a firewall stalls in connect, so the cap has to be short enough that quitting +# still feels immediate. +SEND_TIMEOUT: tuple[float, float] = (2.0, 3.0) + _FIRST_RUN_CACHED: bool | None = None diff --git a/strix/telemetry/logging.py b/strix/telemetry/logging.py index 6685eac0..75265e33 100644 --- a/strix/telemetry/logging.py +++ b/strix/telemetry/logging.py @@ -87,6 +87,33 @@ def configure_dependency_logging() -> None: logging.getLogger("asyncio").setLevel(logging.CRITICAL) logging.getLogger("asyncio").propagate = False warnings.filterwarnings("ignore", category=RuntimeWarning, module="asyncio") + _silence_urllib3_finalizer_noise() + + +_unraisable_hook_installed = False + + +def _is_urllib3_closed_file_noise(unraisable: sys.UnraisableHookArgs) -> bool: + return ( + isinstance(unraisable.exc_value, ValueError) + and "I/O operation on closed file" in str(unraisable.exc_value) + and type(unraisable.object).__module__.split(".")[0] == "urllib3" + ) + + +def _silence_urllib3_finalizer_noise() -> None: + global _unraisable_hook_installed # noqa: PLW0603 + if _unraisable_hook_installed: + return + _unraisable_hook_installed = True + previous = sys.unraisablehook + + def hook(unraisable: sys.UnraisableHookArgs) -> None: + if _is_urllib3_closed_file_noise(unraisable): + return + previous(unraisable) + + sys.unraisablehook = hook def setup_scan_logging(run_dir: Path, *, debug: bool | None = None) -> Callable[[], None]: diff --git a/strix/telemetry/posthog.py b/strix/telemetry/posthog.py index 9d6e3907..083e2c95 100644 --- a/strix/telemetry/posthog.py +++ b/strix/telemetry/posthog.py @@ -6,6 +6,7 @@ import requests from strix.config import load_settings from strix.telemetry._common import ( + SEND_TIMEOUT, SESSION_ID, base_props, is_first_run, @@ -37,7 +38,8 @@ def _send(event: str, properties: dict[str, Any]) -> bool: "distinct_id": SESSION_ID, "properties": properties, } - requests.post(f"{_POSTHOG_HOST}/capture/", json=payload, timeout=10) + with requests.post(f"{_POSTHOG_HOST}/capture/", json=payload, timeout=SEND_TIMEOUT): + pass except Exception: # noqa: BLE001 logger.debug("posthog send failed for event %s", event, exc_info=True) return False diff --git a/strix/telemetry/scarf.py b/strix/telemetry/scarf.py index 8da40b72..161e9980 100644 --- a/strix/telemetry/scarf.py +++ b/strix/telemetry/scarf.py @@ -9,6 +9,7 @@ import requests from strix.config import load_settings from strix.telemetry._common import ( + SEND_TIMEOUT, SESSION_ID, base_props, get_version, @@ -43,7 +44,8 @@ def _send(event: str, properties: dict[str, Any]) -> bool: url = f"{_SCARF_ENDPOINT}{path}" if query: url = f"{url}?{query}" - requests.post(url, timeout=10) + with requests.post(url, timeout=SEND_TIMEOUT): + pass except Exception: # noqa: BLE001 logger.debug("scarf send failed for event %s", event, exc_info=True) return False diff --git a/strix/tools/web_search/tool.py b/strix/tools/web_search/tool.py index 04482755..796a950e 100644 --- a/strix/tools/web_search/tool.py +++ b/strix/tools/web_search/tool.py @@ -68,9 +68,9 @@ def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error clas } try: - response = requests.post(url, headers=headers, json=payload, timeout=300) - response.raise_for_status() - content = response.json()["choices"][0]["message"]["content"] + with requests.post(url, headers=headers, json=payload, timeout=300) as response: + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"] except requests.exceptions.Timeout: logger.warning("web_search timed out") return { diff --git a/tests/test_cli_target_list.py b/tests/test_cli_target_list.py index 35982da3..ce5f15f7 100644 --- a/tests/test_cli_target_list.py +++ b/tests/test_cli_target_list.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib +import json import sys from types import SimpleNamespace from typing import TYPE_CHECKING, Any @@ -83,3 +84,84 @@ def test_parse_arguments_rejects_resume_with_target_list( cli_main.parse_arguments() assert "Cannot combine --resume with --target/--target-list" in capsys.readouterr().err + + +def _write_run_record(runs_dir: Path, run_name: str, record: dict[str, Any]) -> None: + """Write a resumable run: its record plus the agent snapshot resume needs.""" + run_dir = runs_dir / run_name + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8") + state_dir = run_dir / ".state" + state_dir.mkdir(exist_ok=True) + (state_dir / "agents.json").write_text("{}", encoding="utf-8") + + +def test_resume_restores_a_target_less_workspace_mount( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A run that only mounted a working directory is resumable.""" + work = tmp_path / "project" + work.mkdir() + monkeypatch.chdir(tmp_path) + _write_run_record( + tmp_path / "strix_runs", + "pentest_abcd", + { + "run_name": "pentest_abcd", + "targets_info": [], + "local_sources": [], + "workspace_mount": str(work), + "instruction": "audit the auth flow", + "scan_mode": "deep", + }, + ) + monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"]) + + args = cli_main.parse_arguments() + + # Still genuinely target-less, and the workspace is mounted again. + assert args.targets_info == [] + assert args.workspace_mount == str(work) + assert args.local_sources == [ + {"source_path": str(work), "workspace_subdir": "project", "protect_metadata": True} + ] + assert args.instruction == "audit the auth flow" + + +def test_resume_reports_a_missing_workspace_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.chdir(tmp_path) + _write_run_record( + tmp_path / "strix_runs", + "pentest_abcd", + { + "run_name": "pentest_abcd", + "targets_info": [], + "local_sources": [], + "workspace_mount": str(tmp_path / "deleted"), + }, + ) + monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"]) + + with pytest.raises(SystemExit): + cli_main.parse_arguments() + + assert "is missing" in capsys.readouterr().err + + +def test_resume_still_requires_targets_or_a_workspace( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.chdir(tmp_path) + _write_run_record( + tmp_path / "strix_runs", + "pentest_abcd", + {"run_name": "pentest_abcd", "targets_info": [], "local_sources": []}, + ) + monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"]) + + with pytest.raises(SystemExit): + cli_main.parse_arguments() + + assert "has no targets_info" in capsys.readouterr().err diff --git a/tests/test_codex_auth.py b/tests/test_codex_auth.py index ba6bb307..98242d8a 100644 --- a/tests/test_codex_auth.py +++ b/tests/test_codex_auth.py @@ -58,6 +58,7 @@ def test_post_form_returns_parsed_body() -> None: resp = mock.MagicMock() resp.status_code = 200 resp.content = b'{"access_token": "tok"}' + resp.__enter__.return_value = resp with mock.patch.object(requests, "post", return_value=resp) as post: data = codex._post_form({"grant_type": "refresh_token"}) diff --git a/tests/test_cost_tracking.py b/tests/test_cost_tracking.py index 065b7cb8..30d4db44 100644 --- a/tests/test_cost_tracking.py +++ b/tests/test_cost_tracking.py @@ -226,8 +226,11 @@ def test_streamed_openrouter_costs_ignores_entries_without_cost() -> None: def test_streamed_openrouter_costs_cleared_on_new_run() -> None: streamed_openrouter_costs.remember("gen-stale", {"cost": 0.7}) - set_global_report_state(ReportState.__new__(ReportState)) - assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-stale")) is None + try: + set_global_report_state(ReportState.__new__(ReportState)) + assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-stale")) is None + finally: + set_global_report_state(None) def test_openrouter_stream_handler_records_cost() -> None: diff --git a/tests/test_go_tui_runtime.py b/tests/test_go_tui_runtime.py new file mode 100644 index 00000000..ee5c0a23 --- /dev/null +++ b/tests/test_go_tui_runtime.py @@ -0,0 +1,907 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import shutil +import socket +import struct +import sys +import threading +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from strix.config.settings import DEFAULT_MAX_TURNS +from strix.interface.tui import runtime as go_tui +from strix.interface.tui import sidecar +from strix.interface.tui.runtime import GoTuiRuntime + + +def args() -> argparse.Namespace: + return argparse.Namespace( + needs_setup=True, + targets_info=[], + instruction=None, + scan_mode="deep", + max_budget_usd=None, + max_turns=DEFAULT_MAX_TURNS, + scope_mode="auto", + diff_base=None, + local_sources=[], + diff_scope={"active": False}, + user_explicit_instruction=None, + run_name="test-run", + ) + + +def test_binary_command_prefers_packaged_sidecar( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + sidecar = tmp_path / "strix-tui" + sidecar.write_text("binary") + monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src") + monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: sidecar) + monkeypatch.setattr( + shutil, + "which", + lambda _name: pytest.fail("PATH lookup should not run"), + ) + + assert GoTuiRuntime.binary_command() == [str(sidecar)] + + +def test_binary_command_prefers_current_source_over_packaged_sidecar( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + source = tmp_path / "tui-src" + source.mkdir() + (source / "go.mod").write_text("module test\n") + sidecar = tmp_path / "strix-tui" + sidecar.write_text("stale") + monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src") + monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: sidecar) + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/go" if name == "go" else None) + + assert GoTuiRuntime.binary_command() == ["go", "run", "./cmd/strix-tui"] + + +def test_binary_command_reports_missing_sidecar( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing") + monkeypatch.setattr(shutil, "which", lambda _name: None) + monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src") + + with pytest.raises(RuntimeError, match="Bubble Tea TUI binary not found"): + GoTuiRuntime.binary_command() + + +def test_binary_command_ignores_unconstrained_path_sidecar( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing") + monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src") + monkeypatch.setattr(shutil, "which", lambda _name: "/untrusted/path/strix-tui") + + with pytest.raises(RuntimeError, match="Bubble Tea TUI binary not found"): + GoTuiRuntime.binary_command() + + +def test_child_environment_excludes_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "openai-secret") + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "aws-id") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "aws-secret") + monkeypatch.setenv("AWS_SESSION_TOKEN", "aws-token") + monkeypatch.setenv("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/aws-token") + monkeypatch.setenv("VERTEXAI_CREDENTIALS", '{"private_key":"secret"}') + monkeypatch.setenv("STRIX_TUI_TOKEN", "stale-transport-token") + monkeypatch.setenv("TERM", "xterm-256color") + + env = sidecar.child_environment() + + assert env["TERM"] == "xterm-256color" + assert "OPENAI_API_KEY" not in env + assert "AWS_ACCESS_KEY_ID" not in env + assert "AWS_SECRET_ACCESS_KEY" not in env + assert "AWS_SESSION_TOKEN" not in env + assert "AWS_WEB_IDENTITY_TOKEN_FILE" not in env + assert "VERTEXAI_CREDENTIALS" not in env + assert "STRIX_TUI_TOKEN" not in env + + +def test_accept_authenticated_connection() -> None: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + address = listener.getsockname() + + def connect() -> None: + with socket.create_connection(address) as connection: + connection.sendall(b"one-use-token") + + thread = threading.Thread(target=connect) + thread.start() + connection = sidecar._accept_authenticated_connection(listener, "one-use-token") + connection.close() + listener.close() + thread.join() + + +def test_rejects_invalid_connection_token() -> None: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + address = listener.getsockname() + + def connect() -> None: + with socket.create_connection(address) as connection: + connection.sendall(b"invalidd-token") + + thread = threading.Thread(target=connect) + thread.start() + with pytest.raises(PermissionError, match="authentication failed"): + sidecar._accept_authenticated_connection(listener, "expected-token") + listener.close() + thread.join() + + +@pytest.mark.asyncio +async def test_windows_transport_launches_without_inherited_fd() -> None: + child = """ +import os +import socket + +host, port = os.environ["STRIX_TUI_ADDR"].rsplit(":", 1) +with socket.create_connection((host, int(port))) as connection: + connection.sendall(os.environ["STRIX_TUI_TOKEN"].encode("ascii")) +""" + env = os.environ.copy() + env.pop("STRIX_TUI_FD", None) + + process, connection = await sidecar._launch_windows_tui_process( + [sys.executable, "-c", child], env, None + ) + connection.close() + + assert await sidecar.wait_process(process) == 0 + + +async def _receive_exactly(connection: socket.socket, size: int) -> bytes: + result = b"" + while len(result) < size: + chunk = await asyncio.get_running_loop().sock_recv(connection, size - len(result)) + if not chunk: + raise EOFError + result += chunk + return result + + +async def _receive_message(connection: socket.socket) -> dict[str, Any]: + size = struct.unpack(">I", await _receive_exactly(connection, 4))[0] + message: dict[str, Any] = json.loads(await _receive_exactly(connection, size)) + return message + + +async def _send_message(connection: socket.socket, message: dict[str, Any]) -> None: + raw = json.dumps(message).encode() + await asyncio.get_running_loop().sock_sendall(connection, struct.pack(">I", len(raw)) + raw) + + +@pytest.mark.asyncio +async def test_runtime_does_not_initialize_or_scan_before_ready( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_args = args() + runtime_args.needs_setup = False + runtime = GoTuiRuntime(runtime_args) + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + calls: list[str] = [] + scan_started = asyncio.Event() + + async def launch( + _command: list[str], _env: dict[str, str], _cwd: str | None + ) -> tuple[SimpleNamespace, socket.socket]: + return SimpleNamespace(returncode=None), backend + + async def wait_process(_process: object) -> int: + await scan_started.wait() + return 0 + + def init_state() -> None: + calls.append("state") + + def start_scan() -> None: + calls.append("scan") + scan_started.set() + + async def preflight(_model: str) -> None: + calls.append("preflight") + + monkeypatch.setattr(runtime, "binary_command", lambda: ["test-sidecar"]) + monkeypatch.setattr(go_tui, "launch_tui_process", launch) + monkeypatch.setattr(go_tui, "wait_process", wait_process) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "persist_current", lambda: None) + monkeypatch.setattr(go_tui, "prepare_run", lambda _args: None) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None) + monkeypatch.setattr(runtime, "init_run_state", init_state) + monkeypatch.setattr(runtime, "start_scan", start_scan) + + run_task = asyncio.create_task(runtime.run()) + try: + hello = await _receive_message(child) + assert hello["type"] == "hello" + assert calls == [] + await _send_message( + child, + { + "version": 3, + "type": "ready", + "payload": { + "capabilities": [ + "state-revisions", + "collection-deltas", + "structured-command-errors", + "agents-collection", + ] + }, + }, + ) + await asyncio.wait_for(run_task, timeout=2) + assert calls == ["preflight", "state", "scan"] + finally: + child.close() + if not run_task.done(): + run_task.cancel() + + +@pytest.mark.asyncio +async def test_pre_activation_failure_propagates_to_dispatcher( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FailedRuntime: + async def run(self) -> None: + raise go_tui.GoTuiPreActivationError("protocol mismatch") + + monkeypatch.setattr(go_tui, "GoTuiRuntime", lambda _args: FailedRuntime()) + + with pytest.raises(go_tui.GoTuiPreActivationError, match="protocol mismatch"): + await go_tui.run_go_tui(args()) + + +@pytest.mark.asyncio +async def test_post_activation_failure_is_surfaced( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class ActivatedRuntime: + async def run(self) -> None: + raise RuntimeError("sidecar failed after ready") + + monkeypatch.setattr(go_tui, "GoTuiRuntime", lambda _args: ActivatedRuntime()) + + with pytest.raises(RuntimeError, match="after ready"): + await go_tui.run_go_tui(args()) + + +@pytest.mark.asyncio +async def test_setup_preflights_model_before_starting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_args = args() + runtime_args.instruction = "CLI instruction" + runtime = GoTuiRuntime(runtime_args) + assert runtime.controller.instruction == "CLI instruction" + runtime.controller.targets = ["https://example.com", "/workspace/mounted"] + runtime.controller.scan_mode = "quick" + runtime.controller.instruction = "" + runtime.controller.max_budget_usd = 8.5 + runtime.controller.max_turns = 321 + runtime.controller.scope_mode = "diff" + runtime.controller.diff_base = "origin/main" + calls: list[str] = [] + + async def preflight(model: str) -> None: + assert model == "openrouter/test-model" + calls.append("preflight") + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + + def build(candidate: argparse.Namespace, **_: object) -> None: + calls.append("targets") + assert candidate.target == ["https://example.com", "/workspace/mounted"] + candidate.targets_info = [ + { + "type": "web", + "details": {"target_url": "https://example.com"}, + "original": "https://example.com", + }, + { + "type": "local_code", + "details": {"target_path": "/workspace/mounted"}, + "original": "/workspace/mounted", + }, + ] + + def prepare(candidate: argparse.Namespace) -> None: + calls.append("prepare") + assert candidate.max_budget_usd == 8.5 + assert candidate.max_turns == 321 + assert candidate.scope_mode == "diff" + assert candidate.diff_base == "origin/main" + + monkeypatch.setattr(go_tui, "build_targets_info", build) + monkeypatch.setattr(go_tui, "prepare_run", prepare) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry")) + monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state")) + monkeypatch.setattr(runtime, "start_scan", lambda: calls.append("scan")) + + await runtime.start_from_setup() + + assert calls == ["preflight", "targets", "prepare", "telemetry", "state", "scan"] + assert runtime.args.scan_mode == "quick" + assert runtime.args.instruction == "" + assert runtime.args.max_budget_usd == 8.5 + assert runtime.args.max_turns == 321 + assert runtime.args.scope_mode == "diff" + assert runtime.args.diff_base == "origin/main" + + +@pytest.mark.asyncio +async def test_optimistic_setup_skips_model_preflight( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = GoTuiRuntime(args()) + runtime.controller.targets = [str(Path.cwd())] + calls: list[str] = [] + + async def preflight(_model: str) -> None: + calls.append("preflight") + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "build_targets_info", lambda _args, **_kw: calls.append("targets")) + monkeypatch.setattr(go_tui, "prepare_run", lambda _args: calls.append("prepare")) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry")) + monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state")) + monkeypatch.setattr(runtime, "start_scan", lambda: calls.append("scan")) + + await runtime.start_from_setup(verify=False) + + # No preflight: the scan launches straight through and any model error + # surfaces once the agent runs. + assert "preflight" not in calls + assert calls == ["targets", "prepare", "telemetry", "state", "scan"] + + +@pytest.mark.asyncio +async def test_confirmed_target_less_launch_mounts_workspace_without_targets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The working directory reaches the run as a workspace, never as a target.""" + runtime = GoTuiRuntime(args()) + runtime.controller.workspace_mount = str(Path.home()) + prepared: list[argparse.Namespace] = [] + + async def preflight(_model: str) -> None: + return None + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr( + go_tui, + "build_targets_info", + lambda _args, **_kw: pytest.fail("a target-less launch must not build targets"), + ) + monkeypatch.setattr(go_tui, "prepare_run", prepared.append) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None) + monkeypatch.setattr(runtime, "init_run_state", lambda: None) + monkeypatch.setattr(runtime, "start_scan", lambda: None) + + await runtime.start_from_setup(verify=False) + + assert prepared[0].workspace_mount == str(Path.home()) + assert prepared[0].targets_info == [] + + +@pytest.mark.asyncio +async def test_setup_preserves_prepared_cli_targets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_args = args() + runtime_args.target = ["https://example.com"] + runtime_args.target_list = [] + runtime_args.targets_info = [ + { + "type": "web", + "details": {"url": "https://example.com"}, + "original": "https://example.com", + } + ] + runtime = GoTuiRuntime(runtime_args) + calls: list[str] = [] + + async def preflight(_model: str) -> None: + calls.append("preflight") + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr( + go_tui, + "build_targets_info", + lambda _args, **_kw: pytest.fail("prepared targets should not be rebuilt"), + ) + monkeypatch.setattr(go_tui, "prepare_run", lambda _args: calls.append("prepare")) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry")) + monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state")) + monkeypatch.setattr(runtime, "start_scan", lambda: calls.append("scan")) + + await runtime.start_from_setup() + + assert runtime.controller.targets == ["https://example.com"] + assert runtime.args.targets_info[0]["type"] == "web" + assert calls == ["preflight", "prepare", "telemetry", "state", "scan"] + + +@pytest.mark.asyncio +async def test_setup_target_change_preserves_local_targets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_args = args() + runtime_args.target = [] + runtime_args.target_list = ["targets.txt"] + runtime_args.targets_info = [ + { + "type": "local_code", + "details": {"target_path": "/workspace/source"}, + "original": "/workspace/source", + } + ] + runtime = GoTuiRuntime(runtime_args) + runtime.controller.targets.append("https://example.com") + + async def preflight(_model: str) -> None: + return None + + def build(target_args: argparse.Namespace, **_: object) -> None: + assert target_args.target == ["/workspace/source", "https://example.com"] + target_args.targets_info = [ + { + "type": "web", + "details": {"url": "https://example.com"}, + "original": "https://example.com", + }, + { + "type": "local_code", + "details": {"target_path": "/workspace/source"}, + "original": "/workspace/source", + }, + ] + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "build_targets_info", build) + monkeypatch.setattr(go_tui, "prepare_run", lambda _args: None) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None) + monkeypatch.setattr(runtime, "init_run_state", lambda: None) + monkeypatch.setattr(runtime, "start_scan", lambda: None) + + await runtime.start_from_setup() + + assert runtime.args.target_list == [] + assert runtime.args.targets_info[0]["type"] == "web" + assert runtime.args.targets_info[1]["type"] == "local_code" + + +@pytest.mark.asyncio +async def test_setup_same_basename_uses_combined_workspace_names_on_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + existing_repo = "https://example.com/first/app.git" + added_repo = "https://example.com/second/app.git" + runtime_args = args() + runtime_args.target = [] + runtime_args.target_list = ["targets.txt"] + runtime_args.targets_info = [ + { + "type": "repository", + "details": { + "target_repo": existing_repo, + "workspace_subdir": "app", + "cloned_repo_path": "/clones/app", + }, + "original": existing_repo, + } + ] + runtime = GoTuiRuntime(runtime_args) + runtime.controller.targets.append(added_repo) + prepare_attempts = 0 + started: list[str] = [] + + async def preflight(_model: str) -> None: + return None + + def build(target_args: argparse.Namespace, **_: object) -> None: + assert target_args.target == [existing_repo, added_repo] + target_args.targets_info = [ + { + "type": "repository", + "details": { + "target_repo": existing_repo, + "workspace_subdir": "app", + }, + "original": existing_repo, + }, + { + "type": "repository", + "details": { + "target_repo": added_repo, + "workspace_subdir": "app-2", + }, + "original": added_repo, + }, + ] + + def prepare(candidate: argparse.Namespace) -> None: + nonlocal prepare_attempts + prepare_attempts += 1 + assert [target["details"]["workspace_subdir"] for target in candidate.targets_info] == [ + "app", + "app-2", + ] + if prepare_attempts == 1: + candidate.targets_info[0]["details"]["target_repo"] = "/mutated" + candidate.targets_info[1]["details"]["workspace_subdir"] = "mutated" + raise ValueError("retry setup") + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "build_targets_info", build) + monkeypatch.setattr(go_tui, "prepare_run", prepare) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None) + monkeypatch.setattr(runtime, "init_run_state", lambda: started.append("state")) + monkeypatch.setattr(runtime, "start_scan", lambda: started.append("scan")) + + with pytest.raises(ValueError, match="retry setup"): + await runtime.start_from_setup() + + assert runtime.args.targets_info[0]["details"] == { + "target_repo": existing_repo, + "workspace_subdir": "app", + "cloned_repo_path": "/clones/app", + } + assert started == [] + + await runtime.start_from_setup() + + assert prepare_attempts == 2 + assert runtime.args.target_list == [] + assert [target["details"]["workspace_subdir"] for target in runtime.args.targets_info] == [ + "app", + "app-2", + ] + assert runtime.args.targets_info[0]["details"]["target_repo"] == existing_repo + assert started == ["state", "scan"] + + +@pytest.mark.asyncio +async def test_setup_target_rebuild_restores_all_target_fields_on_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_args = args() + runtime_args.target = None + runtime_args.target_list = ["targets.txt"] + runtime_args.targets_info = [ + { + "type": "local_code", + "details": {"target_path": "/workspace/source"}, + "original": "/workspace/source", + } + ] + original_targets_info = json.loads(json.dumps(runtime_args.targets_info)) + runtime = GoTuiRuntime(runtime_args) + runtime.controller.targets.append("https://example.com") + + async def preflight(_model: str) -> None: + return None + + def fail_rebuild(target_args: argparse.Namespace, **_: object) -> None: + target_args.target = ["mutated"] + target_args.target_list = ["mutated.txt"] + target_args.targets_info = [{"original": "partial"}] + raise ValueError("bad target") + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "build_targets_info", fail_rebuild) + + with pytest.raises(ValueError, match="bad target"): + await runtime.start_from_setup() + + assert runtime.args.target is None + assert runtime.args.target_list == ["targets.txt"] + assert runtime.args.targets_info == original_targets_info + + +@pytest.mark.asyncio +async def test_setup_rebuild_canonicalizes_relative_local_target( + tmp_path: Any, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = tmp_path / "source" + source.mkdir() + monkeypatch.chdir(tmp_path) + runtime_args = args() + runtime_args.target = [] + runtime_args.target_list = [] + runtime = GoTuiRuntime(runtime_args) + runtime.controller.targets = ["source"] + prepared = False + + async def preflight(_model: str) -> None: + return None + + def prepare(candidate: argparse.Namespace) -> None: + nonlocal prepared + prepared = True + assert len(candidate.targets_info) == 1 + assert candidate.targets_info[0]["details"]["target_path"] == str(source.resolve()) + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "prepare_run", prepare) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None) + monkeypatch.setattr(runtime, "init_run_state", lambda: None) + monkeypatch.setattr(runtime, "start_scan", lambda: None) + + await runtime.start_from_setup() + + assert prepared is True + assert runtime.args.targets_info[0]["original"] == str(source.resolve()) + + +@pytest.mark.asyncio +@pytest.mark.asyncio +async def test_setup_prepare_system_exit_is_recoverable_and_transactional( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_args = args() + runtime_args.instruction = "CLI instruction" + runtime_args.scan_mode = "deep" + runtime_args.target = ["https://example.com"] + runtime_args.target_list = [] + runtime_args.targets_info = [ + { + "type": "web", + "details": {"url": "https://example.com"}, + "original": "https://example.com", + } + ] + original_args = json.loads(json.dumps(vars(runtime_args))) + runtime = GoTuiRuntime(runtime_args) + runtime.controller.scan_mode = "quick" + runtime.controller.instruction = "" + telemetry_started = False + + async def preflight(_model: str) -> None: + return None + + def fail_prepare(candidate: argparse.Namespace) -> None: + assert candidate is not runtime.args + candidate.run_name = "mutated-run" + candidate.targets_info[0]["details"]["url"] = "https://mutated.example" + raise ValueError("invalid diff scope") + + def telemetry(_candidate: argparse.Namespace) -> None: + nonlocal telemetry_started + telemetry_started = True + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "prepare_run", fail_prepare) + monkeypatch.setattr(go_tui, "telemetry_start", telemetry) + + with pytest.raises(ValueError, match="invalid diff scope"): + await runtime.start_from_setup() + + assert vars(runtime.args) == original_args + assert telemetry_started is False + assert runtime.scan_task is None + + +@pytest.mark.asyncio +async def test_scan_passes_max_turns_and_budget(monkeypatch: pytest.MonkeyPatch) -> None: + runtime_args = args() + runtime_args.max_turns = 37 + runtime_args.max_budget_usd = 4.25 + runtime = GoTuiRuntime(runtime_args) + runtime.scan_config = {"run_name": "test-run"} + captured: dict[str, Any] = {} + + async def run_scan(**kwargs: Any) -> None: + captured.update(kwargs) + coordinator = kwargs["coordinator"] + await coordinator.register("root", "Root", parent_id=None) + await coordinator.set_status("root", "stopped") + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(runtime=SimpleNamespace(image="test-image")), + ) + monkeypatch.setattr(go_tui, "run_strix_scan", run_scan) + + await runtime._run_scan() + + assert captured["max_turns"] == 37 + assert captured["max_budget_usd"] == 4.25 + assert runtime.controller.scan_state == "stopped" + + +@pytest.mark.asyncio +async def test_setup_preflight_failure_does_not_start_scan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = GoTuiRuntime(args()) + runtime.controller.targets = ["https://example.com"] + started = False + + async def preflight(_model: str) -> None: + raise ValueError("401 Unauthorized") + + def mark_started(*_args: Any) -> None: + nonlocal started + started = True + + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), + ) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "build_targets_info", mark_started) + monkeypatch.setattr(runtime, "init_run_state", mark_started) + monkeypatch.setattr(runtime, "start_scan", mark_started) + + with pytest.raises(RuntimeError, match="Model connection failed: 401 Unauthorized"): + await runtime.start_from_setup() + + assert started is False + assert runtime.scan_task is None + + +@pytest.mark.asyncio +async def test_agent_state_sync_uses_latest_graph_snapshot_shape() -> None: + runtime = GoTuiRuntime(args()) + await runtime.coordinator.register("root", "Strix", parent_id=None) + await runtime.coordinator.register("child", "Recon", parent_id="root") + await runtime.coordinator.set_status("child", "failed", error="provider rejected request") + + await runtime._sync_agent_state() + + assert runtime.live_view.agents["root"]["name"] == "Strix" + child = runtime.live_view.agents["child"] + assert child["name"] == "Recon" + assert child["parent_id"] == "root" + assert child["status"] == "failed" + assert child["error_message"] == "provider rejected request" + + +@pytest.mark.asyncio +async def test_agent_state_sync_projects_completed_report() -> None: + runtime = GoTuiRuntime(args()) + runtime.report_state = cast("Any", SimpleNamespace(run_record={"status": "completed"})) + await runtime.coordinator.register("root", "Strix", parent_id=None) + await runtime.coordinator.set_status("root", "completed") + + await runtime._sync_agent_state() + + assert runtime.controller.scan_state == "completed" + + +@pytest.mark.asyncio +async def test_agent_state_sync_does_not_mask_root_failure_with_completed_report() -> None: + runtime = GoTuiRuntime(args()) + runtime.report_state = cast("Any", SimpleNamespace(run_record={"status": "completed"})) + await runtime.coordinator.register("root", "Strix", parent_id=None) + await runtime.coordinator.set_status("root", "failed", error="finalization failed") + + await runtime._sync_agent_state() + + assert runtime.controller.scan_state == "failed" + assert runtime.controller.error == "finalization failed" + + +def _direct_launch_args() -> argparse.Namespace: + launch_args = args() + launch_args.needs_setup = False + return launch_args + + +@pytest.mark.asyncio +async def test_prepare_and_start_reports_ordinary_connection_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = GoTuiRuntime(_direct_launch_args()) + started: list[str] = [] + + async def preflight(_model: str) -> None: + raise TimeoutError("connection timed out") + + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(runtime, "start_scan", lambda: started.append("scan")) + + await runtime.prepare_and_start() + + assert started == [] + assert runtime.controller.setup_mode is False + assert runtime.controller.scan_state == "failed" + assert "connection timed out" in (runtime.controller.error or "") + + +@pytest.mark.asyncio +async def test_prepare_and_start_runs_the_scan_after_preparation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = GoTuiRuntime(_direct_launch_args()) + order: list[str] = [] + + async def preflight(_model: str) -> None: + order.append("preflight") + + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "persist_current", lambda: order.append("persist")) + monkeypatch.setattr(go_tui, "prepare_run", lambda _args: order.append("prepare")) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: order.append("telemetry")) + monkeypatch.setattr(runtime, "init_run_state", lambda: order.append("state")) + monkeypatch.setattr(runtime, "start_scan", lambda: order.append("scan")) + + await runtime.prepare_and_start() + + assert order == ["preflight", "persist", "prepare", "telemetry", "state", "scan"] + assert runtime.controller.scan_state == "running" diff --git a/tests/test_inputs.py b/tests/test_inputs.py index 83a431ad..ed233262 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -8,7 +8,12 @@ from typing import Any import litellm import pytest -from strix.core.inputs import build_root_task, child_initial_input, make_model_settings +from strix.core.inputs import ( + build_root_task, + build_scope_context, + child_initial_input, + make_model_settings, +) def _child_kwargs(parent_history: list[Any]) -> dict[str, Any]: @@ -202,6 +207,34 @@ def test_build_root_task_web_application_with_instructions() -> None: assert "Special instructions: Focus on auth." in task +def test_build_root_task_workspace_mount_is_not_a_target() -> None: + """A target-less run gets a working directory, not an assessment scope.""" + config = { + "targets": [], + "user_instructions": "Find IDOR in the checkout flow.", + "workspace_mount": "/Users/me/code/api", + "workspace_subdir": "api", + } + task = build_root_task(config) + + assert "Working Directory:" in task + assert "/workspace/api" in task + assert "No scan target was set" in task + assert "Special instructions: Find IDOR in the checkout flow." in task + # It must not be presented as an asset to test. + for label in ("Local Codebases:", "Repositories:", "URLs:", "IP Addresses:"): + assert label not in task + + +def test_build_scope_context_authorizes_nothing_without_targets() -> None: + """A mounted workspace grants no authorized scope.""" + scope = build_scope_context( + {"targets": [], "workspace_mount": "/Users/me/code/api", "workspace_subdir": "api"} + ) + + assert scope["authorized_targets"] == [] + + def test_build_root_task_diff_scope() -> None: config = { "targets": [], diff --git a/tests/test_local_sources.py b/tests/test_local_sources.py index 5c19b937..9984bef6 100644 --- a/tests/test_local_sources.py +++ b/tests/test_local_sources.py @@ -2,11 +2,13 @@ from __future__ import annotations +import argparse from pathlib import Path from typing import Any import pytest +from strix.interface.scan_setup import attach_workspace_mount from strix.interface.utils import ( check_mountable_dir, collect_local_sources, @@ -14,6 +16,7 @@ from strix.interface.utils import ( infer_target_type, read_target_list_file, ) +from strix.runtime.session_manager import build_bind_mounts def _local_target(target_path: str) -> dict[str, Any]: @@ -66,6 +69,55 @@ def test_check_mountable_dir_rejects_home(tmp_path: Path, monkeypatch: pytest.Mo check_mountable_dir(home) +def test_infer_target_type_guards_sensitive_dirs_by_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: home)) + + with pytest.raises(ValueError, match="Refusing to mount"): + infer_target_type(str(home)) + + +def test_workspace_mount_is_mounted_without_becoming_a_target( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A workspace mount reaches the sandbox but carries no target semantics. + + It is the directory the agent works in, so it is exempt from the guard that + refuses home directories for scan targets, and it never enters targets_info. + """ + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: home)) + args = argparse.Namespace(targets_info=[], local_sources=[], workspace_mount=str(home)) + + attach_workspace_mount(args) + + assert args.targets_info == [] + assert args.local_sources == [ + { + "source_path": str(home), + "workspace_subdir": args.workspace_subdir, + "protect_metadata": True, + } + ] + # It is a real bind mount, so the sandbox exposes it under /workspace. + assert build_bind_mounts(args.local_sources)[0]["target"] == ( + f"/workspace/{args.workspace_subdir}" + ) + + +def test_workspace_mount_absent_leaves_local_sources_alone() -> None: + args = argparse.Namespace(targets_info=[], local_sources=[], workspace_mount=None) + + attach_workspace_mount(args) + + assert args.local_sources == [] + + def test_check_mountable_dir_rejects_system_root() -> None: etc = Path("/etc") if not etc.is_dir(): diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 00000000..6e3a2261 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def test_wheel_build_requires_go(tmp_path: Path) -> None: + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is required for the packaging smoke test") + + env = os.environ.copy() + env["PATH"] = str(tmp_path / "path-without-go") + result = subprocess.run( # noqa: S603 + [uv, "build", "--wheel", "--out-dir", str(tmp_path / "dist")], + cwd=PROJECT_ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "Go 1.24 or newer is required" in result.stdout + result.stderr diff --git a/tests/test_proxy_renderer.py b/tests/test_proxy_renderer.py deleted file mode 100644 index 341a1f40..00000000 --- a/tests/test_proxy_renderer.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Tests for the proxy tool TUI renderers.""" - -from __future__ import annotations - -from rich.text import Text - -from strix.interface.tui.renderers.proxy_renderer import ViewRequestRenderer - - -def _plain(static: object) -> str: - content = static.content # type: ignore[attr-defined] - return content.plain if isinstance(content, Text) else str(content) - - -def _render(content: str, *, has_more: bool) -> str: - tool_data = { - "status": "completed", - "result": { - "content": content, - "has_more": has_more, - "page": 1, - "total_lines": len(content.split("\n")), - }, - } - return _plain(ViewRequestRenderer.render(tool_data)) - - -_MARKER = "... more content available" - - -def test_more_content_hint_shown_when_over_fifteen_lines() -> None: - content = "\n".join(f"line{i}" for i in range(30)) - - assert _MARKER in _render(content, has_more=False) - - -def test_no_more_content_hint_within_fifteen_lines() -> None: - content = "\n".join(f"line{i}" for i in range(5)) - - assert _MARKER not in _render(content, has_more=False) - - -def test_more_content_hint_shown_when_has_more_flag_set() -> None: - content = "\n".join(f"line{i}" for i in range(3)) - - assert _MARKER in _render(content, has_more=True) diff --git a/tests/test_tui_backend_controller.py b/tests/test_tui_backend_controller.py new file mode 100644 index 00000000..f6cebe13 --- /dev/null +++ b/tests/test_tui_backend_controller.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +import argparse +import asyncio +import os +from pathlib import Path + +import pytest + +from strix.config import apply_config_override, loader +from strix.config.settings import DEFAULT_MAX_TURNS +from strix.interface.tui.backend.controller import TuiController + + +def args() -> argparse.Namespace: + return argparse.Namespace( + needs_setup=True, + targets_info=[], + instruction=None, + scan_mode="deep", + max_budget_usd=None, + max_turns=DEFAULT_MAX_TURNS, + scope_mode="auto", + diff_base=None, + local_sources=[], + diff_scope={"active": False}, + user_explicit_instruction=None, + run_name=None, + ) + + +@pytest.fixture(autouse=True) +def isolated_config(tmp_path: Path) -> None: + for key in ( + "STRIX_LLM", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "LLM_API_KEY", + "LLM_API_BASE", + "AZURE_API_KEY", + "AZURE_API_BASE", + "AZURE_API_VERSION", + ): + os.environ.pop(key, None) + apply_config_override(tmp_path / "config.json") + + +@pytest.mark.asyncio +async def test_setup_state_is_serializable() -> None: + controller = TuiController(args()) + await controller.handle("setup.add_target", {"target": "https://example.com"}) + await controller.handle("setup.set_instruction", {"instruction": "focus on auth"}) + snapshot = controller.snapshot() + assert snapshot["targets"] == ["https://example.com"] + assert snapshot["instruction"] == "focus on auth" + assert snapshot["scan_state"] == "setup" + assert snapshot["scan_mode"] == "deep" + assert snapshot["max_budget_usd"] is None + assert snapshot["max_turns"] == 500 + assert snapshot["scope_mode"] == "auto" + assert snapshot["diff_base"] is None + + +@pytest.mark.asyncio +async def test_setup_instruction_starts_from_cli_and_can_be_cleared() -> None: + setup_args = args() + setup_args.instruction = " CLI instruction " + controller = TuiController(setup_args) + + assert controller.snapshot()["instruction"] == "CLI instruction" + + result = await controller.handle("setup.set_instruction", {"instruction": ""}) + + assert result == {"instruction": ""} + assert controller.snapshot()["instruction"] == "" + + +@pytest.mark.asyncio +async def test_setup_controls_reject_changes_after_start() -> None: + controller = TuiController(args()) + controller.setup_mode = False + controller.scan_started = True + + with pytest.raises(RuntimeError, match="can no longer be changed"): + await controller.handle("setup.add_target", {"target": "https://example.com"}) + + +@pytest.mark.asyncio +async def test_large_target_list_reports_truncated_snapshot_count() -> None: + controller = TuiController(args()) + + for index in range(20): + await controller.handle("setup.add_target", {"target": f"https://target-{index}.example"}) + added = await controller.handle("setup.add_target", {"target": "https://last.example"}) + snapshot = controller.snapshot() + + assert added == {"target": "https://last.example", "total": 21} + assert snapshot["target_count"] == 21 + # The snapshot only carries a bounded prefix of the list. + assert len(snapshot["targets"]) == 16 + + +def test_state_populates_model_warning_for_non_frontier_model() -> None: + os.environ["STRIX_LLM"] = "openai/gpt-3.5-turbo" + loader._cached = None + + warning = TuiController(args()).snapshot()["model_warning"] + + assert "openai/gpt-3.5-turbo" in warning + assert "not a recommended frontier model" in warning + + +def test_setup_restores_prepared_cli_targets() -> None: + setup_args = args() + setup_args.targets_info = [ + {"type": "web", "details": {}, "original": "https://example.com"}, + {"type": "local_code", "details": {}, "original": "/workspace/source"}, + ] + + controller = TuiController(setup_args) + + assert controller.snapshot()["targets"] == ["https://example.com", "/workspace/source"] + + +@pytest.mark.asyncio +async def test_start_validates_model_before_callback() -> None: + started = False + + async def start(_verify: bool = True) -> None: + nonlocal started + started = True + + controller = TuiController(args(), on_start=start) + await controller.handle("setup.add_target", {"target": "https://example.com"}) + with pytest.raises(ValueError, match="No model configured"): + await controller.handle("setup.start", {}) + assert started is False + + +@pytest.mark.asyncio +async def test_start_launches_with_a_configured_model() -> None: + started = False + + async def start(_verify: bool = True) -> None: + nonlocal started + started = True + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + loader._cached = None + controller = TuiController(args(), on_start=start) + await controller.handle("setup.add_target", {"target": "https://example.com"}) + + result = await controller.handle("setup.start", {}) + + assert result == {"started": True} + assert started is True + + +@pytest.mark.asyncio +async def test_start_without_target_requires_mount_consent() -> None: + started = False + + async def start(_verify: bool = True) -> None: + nonlocal started + started = True + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start) + + # Mounting the working directory is never silent. + with pytest.raises(ValueError, match="No target set"): + await controller.handle("setup.start", {"verify": False}) + assert started is False + assert controller.targets == [] + assert controller.workspace_mount is None + + +@pytest.mark.asyncio +async def test_target_less_start_enters_live_view_and_waits_for_the_mount() -> None: + """Nothing is prepared until the live-view confirmation is answered.""" + started = False + + async def start(_verify: bool = True) -> None: + nonlocal started + started = True + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start) + + result = await controller.handle("setup.start", {"verify": False, "mount_working_dir": True}) + + assert result == {"started": True} + # The live view is up so the prompt can be shown there, but the scan has not + # been prepared and nothing is mounted yet. + assert started is False + assert controller.setup_mode is False + assert controller.scan_state == "preparing" + assert controller.pending_workspace_mount == str(Path.cwd()) + assert controller.workspace_mount is None + assert controller.snapshot()["pending_mount"] == str(Path.cwd()) + + +@pytest.mark.asyncio +async def test_confirming_the_mount_starts_the_scan_without_a_target() -> None: + started = False + seen_verify: bool | None = None + + async def start(verify: bool = True) -> None: + nonlocal started, seen_verify + started = True + seen_verify = verify + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start) + await controller.handle("setup.start", {"verify": False, "mount_working_dir": True}) + + result = await controller.handle("setup.confirm_mount", {"approved": True}) + + assert result == {"approved": True} + assert started is True + # Launched optimistically, and mounted as a workspace: the scan genuinely + # has no target, so the instruction is the only source of truth. + assert seen_verify is False + assert controller.workspace_mount == str(Path.cwd()) + assert controller.targets == [] + assert controller.scan_state == "running" + assert controller.snapshot()["pending_mount"] == "" + + +@pytest.mark.asyncio +async def test_declining_the_mount_returns_to_the_start_screen() -> None: + started = False + + async def start(_verify: bool = True) -> None: + nonlocal started + started = True + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start) + await controller.handle("setup.start", {"verify": False, "mount_working_dir": True}) + + result = await controller.handle("setup.confirm_mount", {"approved": False}) + + assert result == {"approved": False} + # Nothing was prepared, so the session goes back to the start screen and can + # be launched again. + assert started is False + assert controller.workspace_mount is None + assert controller.pending_workspace_mount is None + assert controller.setup_mode is True + assert controller.scan_started is False + assert controller.scan_state == "setup" + + +@pytest.mark.asyncio +async def test_confirm_mount_requires_a_pending_request() -> None: + controller = TuiController(args()) + + with pytest.raises(RuntimeError, match="No mount confirmation is pending"): + await controller.handle("setup.confirm_mount", {"approved": True}) + + +def test_snapshot_exposes_working_directory() -> None: + controller = TuiController(args()) + + assert controller.snapshot()["working_dir"] == str(Path.cwd()) + assert controller.snapshot()["pending_mount"] == "" + + +@pytest.mark.asyncio +async def test_start_forwards_verify_flag_by_default() -> None: + seen_verify: bool | None = None + + async def start(verify: bool = True) -> None: + nonlocal seen_verify + seen_verify = verify + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start) + await controller.handle("setup.add_target", {"target": "https://example.com"}) + + # A named target keeps the upfront model check. + await controller.handle("setup.start", {}) + + assert seen_verify is True + + +@pytest.mark.asyncio +async def test_start_rejects_concurrent_and_repeated_submissions() -> None: + entered = asyncio.Event() + release = asyncio.Event() + + async def start(_verify: bool = True) -> None: + entered.set() + await release.wait() + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start) + await controller.handle("setup.add_target", {"target": "https://example.com"}) + + first_start = asyncio.create_task(controller.handle("setup.start", {})) + await entered.wait() + with pytest.raises(RuntimeError, match="already starting or running"): + await controller.handle("setup.start", {}) + release.set() + await first_start + with pytest.raises(RuntimeError, match="already starting or running"): + await controller.handle("setup.start", {}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["completed", "failed", "crashed", "stopped"]) +async def test_stop_rejects_terminal_agents(status: str) -> None: + class Coordinator: + def __init__(self) -> None: + self.calls: list[str] = [] + + async def cancel_descendants_graceful(self, agent_id: str) -> bool: + self.calls.append(agent_id) + return True + + coordinator = Coordinator() + controller = TuiController(args(), coordinator=coordinator) + controller.set_runtime(scan_loop=asyncio.get_running_loop()) + controller.live_view.upsert_agent("agent-1", name="Agent", status=status) + + with pytest.raises(RuntimeError, match=f"cannot be stopped while {status}"): + await controller.handle("agent.stop", {"agent_id": "agent-1"}) + + assert coordinator.calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["running", "waiting", "budget_paused"]) +async def test_stop_allows_active_agents(status: str) -> None: + class Coordinator: + def __init__(self) -> None: + self.calls: list[str] = [] + + async def cancel_descendants_graceful(self, agent_id: str) -> bool: + self.calls.append(agent_id) + return True + + coordinator = Coordinator() + controller = TuiController(args(), coordinator=coordinator) + controller.set_runtime(scan_loop=asyncio.get_running_loop()) + controller.live_view.upsert_agent("agent-1", name="Agent", status=status) + + result = await controller.handle("agent.stop", {"agent_id": "agent-1"}) + + assert result == {"stopped": True} + assert coordinator.calls == ["agent-1"] + + +@pytest.mark.asyncio +async def test_stop_handles_coordinator_rejection_after_stale_active_projection() -> None: + class Coordinator: + async def cancel_descendants_graceful(self, _agent_id: str) -> bool: + return False + + controller = TuiController(args(), coordinator=Coordinator()) + controller.set_runtime(scan_loop=asyncio.get_running_loop()) + controller.live_view.upsert_agent("agent-1", name="Agent", status="running") + + with pytest.raises(RuntimeError, match="no longer active"): + await controller.handle("agent.stop", {"agent_id": "agent-1"}) + + +@pytest.mark.asyncio +async def test_unknown_command_is_rejected() -> None: + controller = TuiController(args()) + with pytest.raises(ValueError, match="Unknown command"): + await controller.handle("nope", {}) + + +def test_messages_are_sanitized_and_agents_are_collection_only() -> None: + controller = TuiController(args()) + controller.add_message("replace\x1b]52;c;Y2xpcA==\x07 key\x85") + for index in range(40): + controller.live_view.upsert_agent(f"agent-{index}", name=f"Agent {index}") + + snapshot = controller.snapshot() + + assert "agents" not in snapshot + assert [message["text"] for message in snapshot["messages"]] == ["replace key"] + assert len(controller.collection("agents")) == 40 + + +@pytest.mark.asyncio +async def test_existing_viewer_is_reopened_and_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + opened: list[str] = [] + + class ViewerServer: + shutdown_called = False + close_called = False + + def shutdown(self) -> None: + self.shutdown_called = True + + def server_close(self) -> None: + self.close_called = True + + controller = TuiController(args()) + controller.viewer_status = "running" + controller.viewer_url = "http://127.0.0.1:1234/?token=test" + server = ViewerServer() + controller._viewer_httpd = server + monkeypatch.setattr("strix.interface.tui.backend.controller.webbrowser.open", opened.append) + + result = await controller.handle("viewer.open", {}) + controller.close_viewer() + + assert result == {"status": "running", "url": controller.viewer_url} + assert opened == [controller.viewer_url] + assert server.shutdown_called is True + assert server.close_called is True diff --git a/tests/test_tui_backend_server.py b/tests/test_tui_backend_server.py new file mode 100644 index 00000000..d3e08088 --- /dev/null +++ b/tests/test_tui_backend_server.py @@ -0,0 +1,560 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import socket +import struct +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from agents.tool import ToolOutputImage + +from strix.config.settings import DEFAULT_MAX_TURNS +from strix.interface.tui.backend.controller import TuiController +from strix.interface.tui.backend.projection import terminal_projection +from strix.interface.tui.backend.protocol import ( + MAX_COMMAND_BYTES, + PROTOCOL_CAPABILITIES, + PROTOCOL_VERSION, + ProtocolHandshakeError, + envelope, +) +from strix.interface.tui.backend.server import TuiBackendServer +from strix.interface.tui.live_view import TuiLiveView + + +def args() -> argparse.Namespace: + return argparse.Namespace( + needs_setup=True, + targets_info=[], + instruction=None, + scan_mode="deep", + max_budget_usd=None, + max_turns=DEFAULT_MAX_TURNS, + scope_mode="auto", + diff_base=None, + local_sources=[], + diff_scope={"active": False}, + user_explicit_instruction=None, + run_name=None, + ) + + +async def send_message(connection: socket.socket, message: dict[str, object]) -> None: + raw = json.dumps(message).encode() + await asyncio.get_running_loop().sock_sendall(connection, struct.pack(">I", len(raw)) + raw) + + +async def receive_exactly(connection: socket.socket, size: int) -> bytes: + chunks: list[bytes] = [] + while size: + chunk = await asyncio.get_running_loop().sock_recv(connection, size) + if not chunk: + raise EOFError + chunks.append(chunk) + size -= len(chunk) + return b"".join(chunks) + + +async def receive_frame(connection: socket.socket) -> tuple[int, dict[str, Any]]: + size = struct.unpack(">I", await receive_exactly(connection, 4))[0] + value = json.loads(await receive_exactly(connection, size)) + assert isinstance(value, dict) + return size, value + + +async def receive_message(connection: socket.socket) -> dict[str, Any]: + return (await receive_frame(connection))[1] + + +async def start_server( + server: TuiBackendServer, backend: socket.socket, child: socket.socket +) -> dict[str, Any]: + start_task = asyncio.create_task(server.start(backend)) + hello = await receive_message(child) + await send_message( + child, + { + "version": PROTOCOL_VERSION, + "type": "ready", + "payload": {"capabilities": list(PROTOCOL_CAPABILITIES)}, + }, + ) + await asyncio.wait_for(start_task, timeout=1) + return hello + + +async def receive_until( + connection: socket.socket, + message_type: str, + *, + request_id: str | None = None, +) -> dict[str, Any]: + for _ in range(100): + message = await asyncio.wait_for(receive_message(connection), timeout=2) + if message.get("type") != message_type: + continue + if request_id is not None and message.get("request_id") != request_id: + continue + return message + raise AssertionError(f"did not receive {message_type}") + + +async def receive_initial_state(connection: socket.socket) -> None: + state_received = False + complete: set[str] = set() + while not state_received or complete != {"agents", "events", "vulnerabilities"}: + message = await asyncio.wait_for(receive_message(connection), timeout=2) + if message["type"] == "state": + state_received = True + elif message["type"] == "collection_bootstrap": + payload = message["payload"] + if payload["done"]: + complete.add(payload["collection"]) + + +@pytest.mark.asyncio +async def test_server_requires_ready_before_state_or_commands() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + server = TuiBackendServer(TuiController(args())) + start_task = asyncio.create_task(server.start(backend)) + try: + hello = await receive_message(child) + assert hello == { + "version": 3, + "type": "hello", + "payload": {"capabilities": list(PROTOCOL_CAPABILITIES)}, + } + with pytest.raises(TimeoutError): + await asyncio.wait_for(receive_message(child), timeout=0.1) + assert not start_task.done() + + await send_message( + child, + { + "version": 3, + "type": "ready", + "payload": {"capabilities": list(PROTOCOL_CAPABILITIES)}, + }, + ) + await asyncio.wait_for(start_task, timeout=1) + assert server.activated is True + assert (await receive_until(child, "state"))["payload"]["revision"] == 1 + finally: + child.close() + start_task.cancel() + await server.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("version", "capabilities"), + [ + (2, list(PROTOCOL_CAPABILITIES)), + (3, ["state-revisions"]), + ], +) +async def test_server_rejects_handshake_mismatch(version: int, capabilities: list[str]) -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + server = TuiBackendServer(TuiController(args())) + start_task = asyncio.create_task(server.start(backend)) + try: + await receive_message(child) + await send_message( + child, + {"version": version, "type": "ready", "payload": {"capabilities": capabilities}}, + ) + with pytest.raises(ProtocolHandshakeError, match="mismatch"): + await asyncio.wait_for(start_task, timeout=1) + assert server.activated is False + finally: + child.close() + await server.close() + + +@pytest.mark.asyncio +async def test_server_command_round_trip_over_inherited_socket() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + server = TuiBackendServer(TuiController(args())) + await start_server(server, backend, child) + try: + await send_message( + child, + { + "version": 3, + "type": "setup.add_target", + "request_id": "test-1", + "payload": {"target": "example.com"}, + }, + ) + result = await receive_until(child, "command_result", request_id="test-1") + assert result["payload"]["ok"] is True + assert result["payload"]["command"] == "setup.add_target" + state = await receive_until(child, "state") + assert state["payload"]["revision"] >= 1 + assert state["payload"]["state"]["targets"] == ["example.com"] + finally: + child.close() + await server.close() + + +def test_unicode_heavy_setup_state_stays_within_control_frame_limit() -> None: + controller = TuiController(args()) + controller.instruction = "πŸ”’" * 10_000 + controller.targets = [f"https://δΎ‹γˆ.{index}/" + "η•Œ" * 500 for index in range(20)] + controller.error = "ε€±" * 10_000 + controller.messages = [ + {"id": str(index), "text": "θ­¦" * 10_000, "level": "warning"} for index in range(10) + ] + controller.report_state = cast( + "Any", + SimpleNamespace( + caido_url="https://δΎ‹γˆ.example/" + "道" * 10_000, + get_total_llm_usage=lambda: {f"model-{index}": "θ²»" * 10_000 for index in range(20)}, + ), + ) + server = TuiBackendServer(controller) + + snapshot = controller.snapshot() + encoded = server._encode(envelope("state", {"revision": 1, "state": snapshot})) + + assert len(encoded) <= MAX_COMMAND_BYTES + assert "πŸ”’".encode() in encoded + assert snapshot["projection_truncated"] is True + + +@pytest.mark.asyncio +async def test_persistence_error_does_not_kill_command_reader( + monkeypatch: pytest.MonkeyPatch, +) -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + controller = TuiController(args()) + calls = 0 + + async def handle(command: str, payload: dict[str, Any]) -> dict[str, Any]: + nonlocal calls + calls += 1 + if calls == 1: + raise OSError("disk is read-only") + return {"command": command, "payload": payload} + + monkeypatch.setattr(controller, "handle", handle) + server = TuiBackendServer(controller) + await start_server(server, backend, child) + try: + for request_id in ("persist-1", "persist-2"): + await send_message( + child, + { + "version": 3, + "type": "setup.select_model", + "request_id": request_id, + "payload": {"provider": "openai", "model": "openai/gpt-5"}, + }, + ) + result = await receive_until(child, "command_result", request_id=request_id) + if request_id == "persist-1": + assert result["payload"]["error"] == { + "code": "persistence_error", + "message": "disk is read-only", + "retryable": True, + } + else: + assert result["payload"]["ok"] is True + assert server._reader_task is not None and not server._reader_task.done() + finally: + child.close() + await server.close() + + +@pytest.mark.asyncio +async def test_invalid_version_error_is_correlated_and_next_command_succeeds() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + server = TuiBackendServer(TuiController(args())) + await start_server(server, backend, child) + try: + await send_message( + child, + { + "version": 2, + "type": "setup.add_target", + "request_id": "bad-version", + "payload": {"target": "ignored.example"}, + }, + ) + rejected = await receive_until(child, "command_result", request_id="bad-version") + assert rejected["payload"]["error"]["code"] == "invalid_request" + + await send_message( + child, + { + "version": 3, + "type": "setup.add_target", + "request_id": "after-error", + "payload": {"target": "example.com"}, + }, + ) + accepted = await receive_until(child, "command_result", request_id="after-error") + assert accepted["payload"]["ok"] is True + finally: + child.close() + await server.close() + + +@pytest.mark.asyncio +async def test_collection_bootstrap_is_chunked_deltas_are_incremental_and_idle_is_silent() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + controller = TuiController(args()) + report_state = SimpleNamespace( + vulnerability_reports=[], + caido_url=None, + get_total_llm_usage=dict, + ) + controller.report_state = cast("Any", report_state) + content = "x" * (64 * 1024) + for index in range(80): + controller.live_view.record_user_message(f"agent-{index}", content) + server = TuiBackendServer(controller) + await start_server(server, backend, child) + try: + event_frames = 0 + event_count = 0 + complete: set[str] = set() + state_received = False + while not (complete == {"agents", "events", "vulnerabilities"} and state_received): + size, message = await asyncio.wait_for(receive_frame(child), timeout=5) + if message["type"] == "state": + state_received = True + if message["type"] != "collection_bootstrap": + continue + payload = message["payload"] + if payload["collection"] == "events": + event_frames += 1 + event_count += len(payload["items"]) + assert size <= 4 * 1024 * 1024 + if payload["done"]: + complete.add(payload["collection"]) + assert event_frames >= 2 + assert event_count == 80 + + server.notify_changed() + with pytest.raises(TimeoutError): + await asyncio.wait_for(receive_message(child), timeout=0.2) + + controller.live_view.record_user_message("agent-new", "delta") + controller.notify_changed() + delta = await receive_until(child, "collection_delta") + assert delta["payload"]["collection"] == "events" + assert delta["payload"]["base_revision"] == 1 + assert len(delta["payload"]["operations"]) == 1 + + report_state.vulnerability_reports.append( + {"id": "vuln-0001", "title": "Incremental finding", "severity": "high"} + ) + controller.notify_changed() + finding_delta = await receive_until(child, "collection_delta") + assert finding_delta["payload"]["collection"] == "vulnerabilities" + assert len(finding_delta["payload"]["operations"]) == 1 + finally: + child.close() + await server.close() + + +@pytest.mark.asyncio +async def test_agents_collection_has_no_state_cap_and_sends_delete_and_resync() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + controller = TuiController(args()) + for index in range(40): + controller.live_view.upsert_agent( + f"agent-{index}", + name=f"Agent {index}", + status="running", + ) + server = TuiBackendServer(controller) + await start_server(server, backend, child) + try: + agents: list[dict[str, Any]] = [] + complete: set[str] = set() + state: dict[str, Any] | None = None + while state is None or complete != {"agents", "events", "vulnerabilities"}: + message = await asyncio.wait_for(receive_message(child), timeout=2) + if message["type"] == "state": + state = message["payload"]["state"] + elif message["type"] == "collection_bootstrap": + payload = message["payload"] + if payload["collection"] == "agents": + agents.extend(payload["items"]) + if payload["done"]: + complete.add(payload["collection"]) + + assert "agents" not in state + assert len(agents) == 40 + + controller.live_view.agents.pop("agent-7") + controller.notify_changed() + delta = await receive_until(child, "collection_delta") + assert delta["payload"]["collection"] == "agents" + assert delta["payload"]["operations"] == [{"op": "delete", "id": "agent-7"}] + + await send_message( + child, + { + "version": 3, + "type": "collection.resync", + "request_id": "resync-agents", + "payload": {"collection": "agents"}, + }, + ) + result = await receive_until(child, "command_result", request_id="resync-agents") + assert result["payload"]["ok"] is True + bootstrap = await receive_until(child, "collection_bootstrap") + assert bootstrap["payload"]["collection"] == "agents" + assert bootstrap["payload"]["revision"] == 3 + assert len(bootstrap["payload"]["items"]) == 39 + finally: + child.close() + await server.close() + + +@pytest.mark.asyncio +async def test_bootstrap_larger_than_64_mib_has_no_total_message_ceiling( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = TuiBackendServer(TuiController(args())) + shared_projection = "x" * (1024 * 1024) + items = [{"id": f"event-{index}", "content": shared_projection} for index in range(65)] + frames: list[dict[str, Any]] = [] + encoded_sizes: list[int] = [] + + async def capture(message: dict[str, Any]) -> None: + encoded_sizes.append(len(server._encode(message))) + frames.append(message) + + monkeypatch.setattr(server, "_send", capture) + + await server._send_collection_frames( + "collection_bootstrap", + {"collection": "events", "revision": 1}, + "items", + items, + ) + + assert sum(len(item["content"]) for item in items) > 64 * 1024 * 1024 + assert len(frames) > 16 + assert max(encoded_sizes) <= 4 * 1024 * 1024 + assert frames[0]["payload"]["cursor"] == 0 + assert frames[-1]["payload"]["next_cursor"] == len(items) + assert frames[-1]["payload"]["done"] is True + + +@pytest.mark.asyncio +async def test_oversized_terminal_projection_is_truncated_without_mutating_history() -> None: + controller = TuiController(args()) + durable = "x" * (2 * 1024 * 1024) + controller.live_view.record_user_message("agent", durable) + + projected = controller.collection("events") + + assert len(projected[0]["data"]["content"]) < len(durable) + assert controller.live_view.events[0]["data"]["content"] == durable + + +def test_terminal_projection_strips_ansi_osc_and_c1_controls() -> None: + controller = TuiController(args()) + hostile = "safe\x1b[31mred\x1b[0m\x1b]52;c;Y2xpcGJvYXJk\x07\x85tail" + controller.live_view.record_user_message("agent", hostile) + + projected = controller.collection_snapshot("events")[1][0]["data"]["content"] + + assert projected == "saferedtail" + assert "\x1b" not in projected + + hostile_mapping = {"header\x1b]52;c;Y2xpcA==\x07": "value"} + assert list(terminal_projection(hostile_mapping)) == ["header"] + assert list(TuiBackendServer._sanitize_wire_value(hostile_mapping)) == ["header"] + + +def test_terminal_event_history_is_bounded_without_changing_durable_sessions() -> None: + controller = TuiController(args()) + for index in range(10_050): + controller.live_view.record_user_message("agent", f"message-{index}") + + _cursor, projected = controller.collection_snapshot("events") + + assert len(controller.live_view.events) == 10_000 + assert len(projected) == 5_000 + assert projected[0]["data"]["content"] == "message-5050" + assert projected[-1]["data"]["content"] == "message-10049" + + +@pytest.mark.asyncio +async def test_oversized_command_frame_is_rejected_before_payload_read() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + server = TuiBackendServer(TuiController(args())) + await start_server(server, backend, child) + try: + await asyncio.get_running_loop().sock_sendall( + child, struct.pack(">I", MAX_COMMAND_BYTES + 1) + ) + assert server._reader_task is not None + await asyncio.wait_for(server._reader_task, timeout=1) + assert server._socket is None + finally: + child.close() + await server.close() + + +@pytest.mark.asyncio +async def test_server_stops_when_peer_closes() -> None: + backend, child = socket.socketpair() + child.setblocking(False) # noqa: FBT003 + server = TuiBackendServer(TuiController(args())) + await start_server(server, backend, child) + child.close() + try: + assert server._reader_task is not None + await asyncio.wait_for(server._reader_task, timeout=1) + finally: + await server.close() + + +def test_image_data_uri_survives_terminal_projection() -> None: + uri = "data:image/png;base64," + "A" * 100_000 + assert terminal_projection(uri) == uri + assert terminal_projection({"type": "image", "image_url": uri})["image_url"] == uri + + oversized = "data:image/png;base64," + "A" * (3 * 1024 * 1024) + assert terminal_projection(oversized) == "[image omitted from terminal projection]" + + +def test_view_image_tool_output_is_normalized_to_image_dict() -> None: + uri = "data:image/png;base64," + "B" * 4000 + view = TuiLiveView() + view._record_tool_output_data( + "agent", + { + "call_id": "c1", + "tool_name": "view_image", + "output": ToolOutputImage(type="image", image_url=uri), + }, + ) + view._record_tool_output_data( + "agent", + { + "call_id": "c2", + "tool_name": "view_image", + "output": [{"type": "input_image", "image_url": uri}], + }, + ) + for event in view.events: + assert event["data"]["result"] == {"type": "image", "image_url": uri} diff --git a/tests/test_tui_protocol_conformance.py b/tests/test_tui_protocol_conformance.py new file mode 100644 index 00000000..3b5500ef --- /dev/null +++ b/tests/test_tui_protocol_conformance.py @@ -0,0 +1,49 @@ +"""Guard against the Python and Go protocol constants drifting apart. + +The wire protocol is declared twice β€” ``strix/interface/tui/backend/protocol.py`` +for the backend and ``strix/interface/tui/internal/protocol/protocol.go`` for the +sidecar. This test parses the Go source shipped in the tree and checks the two +declarations agree, so a version or capability change in one language cannot +land silently without the other. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from strix.interface.tui.backend.protocol import PROTOCOL_CAPABILITIES, PROTOCOL_VERSION + + +GO_PROTOCOL_SOURCE = ( + Path(__file__).resolve().parents[1] + / "strix" + / "interface" + / "tui" + / "internal" + / "protocol" + / "protocol.go" +) + + +def test_go_protocol_source_is_present() -> None: + assert GO_PROTOCOL_SOURCE.is_file() + + +def test_protocol_version_matches_go() -> None: + source = GO_PROTOCOL_SOURCE.read_text(encoding="utf-8") + match = re.search(r"^const Version = (\d+)$", source, flags=re.MULTILINE) + assert match is not None, "const Version not found in protocol.go" + assert int(match.group(1)) == PROTOCOL_VERSION + + +def test_protocol_capabilities_match_go() -> None: + source = GO_PROTOCOL_SOURCE.read_text(encoding="utf-8") + match = re.search( + r"^var Capabilities = \[\]string\{\n(?P(?:\t\"[^\"]+\",\n)+)\}", + source, + flags=re.MULTILINE, + ) + assert match is not None, "var Capabilities not found in protocol.go" + go_capabilities = re.findall(r"\"([^\"]+)\"", match.group("body")) + assert tuple(go_capabilities) == PROTOCOL_CAPABILITIES diff --git a/tests/test_tui_resume_history.py b/tests/test_tui_resume_history.py new file mode 100644 index 00000000..f1edd8e8 --- /dev/null +++ b/tests/test_tui_resume_history.py @@ -0,0 +1,281 @@ +"""Resumed history must attribute only typed messages to the user. + +Guidance the system feeds an agent is injected as a user turn, so replayed +history cannot tell it apart from a typed message by role alone. A live run only +shows what the user actually typed; resuming has to match that. +""" + +from __future__ import annotations + +import json +import sqlite3 +from typing import TYPE_CHECKING, Any + +import pytest + +from strix.core.paths import runtime_state_dir +from strix.interface.tui.backend.live_view import TuiLiveView as GoTuiLiveView +from strix.interface.tui.live_view import TuiLiveView, _is_internal_agent_turn + + +if TYPE_CHECKING: + from pathlib import Path + + +def _write_run(run_dir: Path, items: list[dict[str, Any]], agent_id: str = "root") -> None: + """Persist an agent snapshot plus a session history for hydration to read.""" + state_dir = runtime_state_dir(run_dir) + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "agents.json").write_text( + json.dumps({"statuses": {agent_id: "running"}, "names": {agent_id: "recon"}}), + encoding="utf-8", + ) + connection = sqlite3.connect(state_dir / "agents.db") + try: + connection.execute( + "create table agent_messages (id integer primary key, session_id text, " + "message_data text, created_at text)" + ) + for index, item in enumerate(items, start=1): + connection.execute( + "insert into agent_messages (id, session_id, message_data, created_at) " + "values (?, ?, ?, ?)", + (index, agent_id, json.dumps(item), f"2026-01-01T00:00:{index:02d}+00:00"), + ) + connection.commit() + finally: + connection.close() + + +def _user_messages(view: TuiLiveView) -> list[str]: + return [ + str(event["data"]["content"]) + for event in view.events + if event.get("type") == "chat" and event["data"].get("role") == "user" + ] + + +def test_resume_hides_system_guidance_injected_as_user_turns(tmp_path: Path) -> None: + run_dir = tmp_path / "run" + _write_run( + run_dir, + [ + # The task the agent was launched with, not a typed message. + {"role": "user", "content": "\n\nURLs: - https://example.com"}, + {"role": "assistant", "content": "starting"}, + { + "role": "user", + "content": "[Message from system (system) | type=auto_resume | priority=normal]\n" + "Waiting timeout reached.", + }, + {"role": "user", "content": "[NOTICE] Turn budget: 350/500 used (70%)."}, + # A stall notice reaches the parent through the coordinator, so it + # arrives wrapped rather than as a bare "[Agent stalled]". + { + "role": "user", + "content": "[Message from recon (a1) | type=stalled | priority=high]\n" + "[Agent stalled] recon (a1) kept ending turns", + }, + { + "role": "user", + "content": "Your previous message ended a turn without a tool call. " + "Plain text never ends execution.", + }, + {"role": "assistant", "content": "continuing"}, + ], + ) + view = TuiLiveView() + + view.hydrate_from_run_dir(run_dir) + + assert _user_messages(view) == [] + # The agent's own side of the conversation is untouched. + assert [ + str(event["data"]["content"]) + for event in view.events + if event.get("type") == "chat" and event["data"].get("role") == "assistant" + ] == ["starting", "continuing"] + + +def test_resume_keeps_messages_the_user_actually_typed(tmp_path: Path) -> None: + run_dir = tmp_path / "run" + _write_run( + run_dir, + [ + {"role": "user", "content": "\n\nURLs: - https://example.com"}, + {"role": "assistant", "content": "starting"}, + {"role": "user", "content": "check the coupon endpoint next"}, + {"role": "assistant", "content": "on it"}, + {"role": "user", "content": "[NOTICE] Turn budget: 350/500 used (70%)."}, + {"role": "user", "content": "stop testing the admin panel"}, + ], + ) + view = TuiLiveView() + + view.hydrate_from_run_dir(run_dir) + + assert _user_messages(view) == [ + "check the coupon endpoint next", + "stop testing the admin panel", + ] + + +def test_resume_treats_each_agents_first_user_turn_as_its_task(tmp_path: Path) -> None: + """Subagents get their task the same way, so it is skipped per agent.""" + run_dir = tmp_path / "run" + state_dir = runtime_state_dir(run_dir) + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "agents.json").write_text( + json.dumps( + { + "statuses": {"root": "running", "child": "running"}, + "names": {"root": "root", "child": "recon"}, + "parent_of": {"child": "root"}, + } + ), + encoding="utf-8", + ) + connection = sqlite3.connect(state_dir / "agents.db") + try: + connection.execute( + "create table agent_messages (id integer primary key, session_id text, " + "message_data text, created_at text)" + ) + rows = [ + ("root", {"role": "user", "content": "\n\nURLs: - https://example.com"}), + ("child", {"role": "user", "content": "Audit the login flow."}), + ("child", {"role": "user", "content": "also try the password reset"}), + ] + for index, (session_id, item) in enumerate(rows, start=1): + connection.execute( + "insert into agent_messages (id, session_id, message_data, created_at) " + "values (?, ?, ?, ?)", + (index, session_id, json.dumps(item), f"2026-01-01T00:00:{index:02d}+00:00"), + ) + connection.commit() + finally: + connection.close() + view = TuiLiveView() + + view.hydrate_from_run_dir(run_dir) + + # Both tasks are skipped; only the follow-up typed at the child remains. + assert _user_messages(view) == ["also try the password reset"] + + +def test_internal_turn_classifier_matches_every_injected_form() -> None: + for content in ( + # Coordinator deliveries, which wrap the stall, terminal and budget notices. + "[Message from recon (a1) | type=information | priority=normal]\nfound it", + "[Message from recon (a1) | type=stalled | priority=high]\n[Agent stalled] recon (a1)", + "[Message from system (system) | type=budget_extended | priority=normal]\n" + "[Budget] extended", + # Budget warnings, the only notices injected without a wrapper. + "[NOTICE] Turn budget: 350/500 used (70%).", + "[URGENT] Scan cost budget: $9.50/$10.00 spent (95%).", + "[CRITICAL] Turn budget: 480/500 used (96%).", + "== Inherited context from parent (background only) ==", + "Your previous message ended a turn without a tool call.", + "Your previous response ended the autonomous Strix run without a lifecycle tool call.", + ): + assert _is_internal_agent_turn(content), content + + +def test_internal_turn_classifier_keeps_bracketed_user_text() -> None: + """A leading bracket is not enough: typed text often starts with one.""" + for content in ( + '[{"id": 1, "role": "admin"}, {"id": 2}]', + "[link](https://example.com) check this endpoint", + "[URGENT] stop testing the admin panel", + "[2026-01-01 12:00:03] ERROR auth failed - look into this", + "[note] creds are admin:hunter2", + "[Agent] can you check this?", + "[]", + "check the coupon endpoint next", + "Use creds admin:hunter2 for the login form", + "stop", + ): + assert not _is_internal_agent_turn(content), content + + +@pytest.mark.parametrize("view_class", [TuiLiveView, GoTuiLiveView]) +def test_user_instruction_opens_the_transcript_when_the_root_agent_appears( + view_class: type[TuiLiveView], +) -> None: + """A live scan has no root agent yet, so the message waits for it. + + Exercised against the projection the Go TUI actually uses as well as the + base one: that subclass overrides upsert_agent without calling back, so a + hook placed there would silently never run. + """ + view = view_class() + + view.set_user_instruction("find IDOR in the checkout flow") + assert _user_messages(view) == [] + + view.upsert_agent("ab12", name="Strix", parent_id=None, status="running") + assert view.flush_user_instruction() is True + assert _user_messages(view) == ["find IDOR in the checkout flow"] + + # Repeated agent syncs and subagents must not repeat it. + view.upsert_agent("cd34", name="recon", parent_id="ab12", status="running") + view.upsert_agent("ab12", status="running") + assert view.flush_user_instruction() is False + assert _user_messages(view) == ["find IDOR in the checkout flow"] + + +def test_blank_user_instruction_adds_nothing() -> None: + view = TuiLiveView() + + view.set_user_instruction(" ") + view.set_user_instruction(None) + view.upsert_agent("ab12", name="Strix", parent_id=None, status="running") + + assert _user_messages(view) == [] + + +def test_replayed_run_opens_with_the_users_instruction(tmp_path: Path) -> None: + """It comes from the run record and sorts ahead of replayed history.""" + run_dir = tmp_path / "run" + _write_run( + run_dir, + [ + {"role": "user", "content": "\n\nURLs: - https://example.com"}, + {"role": "assistant", "content": "starting"}, + {"role": "user", "content": "also check coupons"}, + ], + ) + (run_dir / "run.json").write_text( + json.dumps( + { + "start_time": "2026-01-01T00:00:00+00:00", + # instruction carries the diff-scope preamble; only the user's own + # text belongs in the transcript. + "instruction": "[diff-scope preamble]\n\naudit the auth flow", + "user_instruction": "audit the auth flow", + } + ), + encoding="utf-8", + ) + view = TuiLiveView() + + view.hydrate_from_run_dir(run_dir) + + assert _user_messages(view) == ["audit the auth flow", "also check coupons"] + first = view.events[0] + assert first["data"]["content"] == "audit the auth flow" + # Stamped with the run's start, so ordering by timestamp keeps it first. + assert first["timestamp"] == "2026-01-01T00:00:00+00:00" + + +def test_replayed_run_without_an_instruction_is_unchanged(tmp_path: Path) -> None: + run_dir = tmp_path / "run" + _write_run(run_dir, [{"role": "assistant", "content": "starting"}]) + (run_dir / "run.json").write_text( + json.dumps({"start_time": "2026-01-01T00:00:00+00:00"}), encoding="utf-8" + ) + view = TuiLiveView() + + view.hydrate_from_run_dir(run_dir) + + assert _user_messages(view) == [] diff --git a/tests/test_unraisable_filter.py b/tests/test_unraisable_filter.py new file mode 100644 index 00000000..83d267ba --- /dev/null +++ b/tests/test_unraisable_filter.py @@ -0,0 +1,53 @@ +import sys + +import pytest +import urllib3.response + +from strix.telemetry import logging as tlog +from strix.telemetry.logging import _is_urllib3_closed_file_noise + + +class _Args: + def __init__(self, exc_value: BaseException | None, obj: object) -> None: + self.exc_type = type(exc_value) if exc_value is not None else None + self.exc_value = exc_value + self.exc_traceback = None + self.err_msg = None + self.object = obj + + +def _urllib3_response() -> urllib3.response.HTTPResponse: + return urllib3.response.HTTPResponse(body=b"") + + +def test_filters_urllib3_closed_file_noise() -> None: + args = _Args(ValueError("I/O operation on closed file."), _urllib3_response()) + assert _is_urllib3_closed_file_noise(args) # type: ignore[arg-type] + + +def test_passes_through_other_unraisables() -> None: + assert not _is_urllib3_closed_file_noise( + _Args(ValueError("I/O operation on closed file."), object()) # type: ignore[arg-type] + ) + assert not _is_urllib3_closed_file_noise( + _Args(RuntimeError("boom"), _urllib3_response()) # type: ignore[arg-type] + ) + assert not _is_urllib3_closed_file_noise( + _Args(ValueError("something else"), _urllib3_response()) # type: ignore[arg-type] + ) + + +def test_installed_hook_filters_and_delegates(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[object] = [] + monkeypatch.setattr(sys, "unraisablehook", calls.append) + monkeypatch.setattr(tlog, "_unraisable_hook_installed", False) + tlog._silence_urllib3_finalizer_noise() + hook = sys.unraisablehook + assert hook is not calls.append + + hook(_Args(ValueError("I/O operation on closed file."), _urllib3_response())) # type: ignore[arg-type] + assert calls == [] + + other = _Args(RuntimeError("boom"), object()) + hook(other) # type: ignore[arg-type] + assert calls == [other] diff --git a/uv.lock b/uv.lock index 8faff233..8b933b4a 100644 --- a/uv.lock +++ b/uv.lock @@ -1077,18 +1077,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] -[[package]] -name = "linkify-it-py" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "uc-micro-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, -] - [[package]] name = "litellm" version = "1.90.1" @@ -1136,14 +1124,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] -[package.optional-dependencies] -linkify = [ - { name = "linkify-it-py" }, -] -plugins = [ - { name = "mdit-py-plugins" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -1232,18 +1212,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] -[[package]] -name = "mdit-py-plugins" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -2427,7 +2395,6 @@ dependencies = [ { name = "reportlab" }, { name = "requests" }, { name = "rich" }, - { name = "textual" }, ] [package.optional-dependencies] @@ -2467,7 +2434,6 @@ requires-dist = [ { name = "reportlab", specifier = ">=4.0" }, { name = "requests", specifier = ">=2.32.0" }, { name = "rich" }, - { name = "textual", specifier = ">=6.0.0" }, ] provides-extras = ["vertex", "bedrock"] @@ -2483,22 +2449,6 @@ dev = [ { name = "ruff", specifier = ">=0.11.13" }, ] -[[package]] -name = "textual" -version = "6.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py", extra = ["linkify", "plugins"] }, - { name = "platformdirs" }, - { name = "pygments" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/30/38b615f7d4b16f6fdd73e4dcd8913e2d880bbb655e68a076e3d91181a7ee/textual-6.2.1.tar.gz", hash = "sha256:4699d8dfae43503b9c417bd2a6fb0da1c89e323fe91c4baa012f9298acaa83e1", size = 1570645, upload-time = "2025-10-01T16:11:24.467Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/93/02c7adec57a594af28388d85da9972703a4af94ae1399542555cd9581952/textual-6.2.1-py3-none-any.whl", hash = "sha256:3c7190633cd4d8bfe6049ae66808b98da91ded2edb85cef54e82bf77b03d2a54", size = 710702, upload-time = "2025-10-01T16:11:22.161Z" }, -] - [[package]] name = "tiktoken" version = "0.13.0" @@ -2633,15 +2583,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] -[[package]] -name = "uc-micro-py" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, -] - [[package]] name = "urllib3" version = "2.7.0"