mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60b1d72642 | ||
|
|
8bd6c8e87a | ||
|
|
68ea6fca65 | ||
|
|
657aa5cbe6 | ||
|
|
82dcd31357 | ||
|
|
6719a70611 | ||
|
|
ea6d53f4e9 | ||
|
|
3bcf3778f0 | ||
|
|
4a455b1e62 | ||
|
|
6f70b6f319 | ||
|
|
23f1d76d4c | ||
|
|
5bb9fe896b | ||
|
|
a51ca18666 |
@@ -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:
|
||||
|
||||
@@ -93,3 +93,8 @@ Thumbs.db
|
||||
schema.graphql
|
||||
|
||||
.opencode/
|
||||
|
||||
# Root-only local data and reference checkouts
|
||||
/.benchmarks/
|
||||
/references/
|
||||
/strix_runs_main/
|
||||
|
||||
@@ -20,7 +20,8 @@ repos:
|
||||
pydantic,
|
||||
fastapi,
|
||||
pytest,
|
||||
"openai-agents[litellm]==0.14.6",
|
||||
hatchling,
|
||||
"openai-agents[litellm]>=0.19.0,<0.20",
|
||||
]
|
||||
args: [--install-types, --non-interactive]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ./...
|
||||
|
||||
@@ -185,6 +185,28 @@ strix --target https://github.com/org/repo
|
||||
strix --target https://your-app.com
|
||||
```
|
||||
|
||||
### API Testing (OpenAPI / Swagger / Postman)
|
||||
|
||||
Point Strix at an API contract and it tests every declared endpoint instead of
|
||||
having to discover them by crawling. Pair the spec with the live base URL so the
|
||||
agent knows where to send traffic:
|
||||
|
||||
```bash
|
||||
# OpenAPI / Swagger file (.json / .yaml)
|
||||
strix --target ./openapi.yaml --target https://api.your-app.com
|
||||
|
||||
# Postman collection export
|
||||
strix --target ./collection.postman_collection.json --target https://api.your-app.com
|
||||
|
||||
# Postman collection pulled live by id (no manual export)
|
||||
export POSTMAN_API_KEY="PMAK-..."
|
||||
strix --target postman://<collection-uuid>
|
||||
|
||||
# ...with a Postman environment to resolve {{baseUrl}} / token variables
|
||||
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
|
||||
```
|
||||
|
||||
|
||||
### Advanced Testing Scenarios
|
||||
|
||||
```bash
|
||||
@@ -311,7 +333,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]
|
||||
|
||||
@@ -80,6 +80,10 @@ affecting the agents that do the actual testing.
|
||||
API key for Perplexity AI. Enables real-time web search during scans for OSINT and vulnerability research.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="POSTMAN_API_KEY" type="string">
|
||||
Postman API key (`PMAK-…`). Enables fetching Postman collections by id as a target (`postman://<collection-uid>`), and Postman environments (`postman://<collection-uid>?env=<environment-uid>`) to resolve collection variables. Not needed when passing a local collection export file.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_TELEMETRY" default="1" type="string">
|
||||
Telemetry toggle. Set to `0`, `false`, `no`, or `off` to disable telemetry (PostHog, Scarf, OTEL).
|
||||
</ParamField>
|
||||
|
||||
@@ -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:
|
||||
|
||||
+13
-1
@@ -12,11 +12,17 @@ strix (--target <target> | --target-list <path>) [options]
|
||||
## Options
|
||||
|
||||
<ParamField path="--target, -t" type="string">
|
||||
Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times. Fresh runs require at least one target source: `--target` or `--target-list`.
|
||||
Target to test. Accepts URLs, repositories, local directories, domains, IP addresses, API spec files (OpenAPI/Swagger `.json`/`.yaml`, a Postman collection export), or a live Postman collection by id (`postman://<collection-uuid>`). Can be specified multiple times. Fresh runs require at least one target source: `--target` or `--target-list`.
|
||||
|
||||
When the target is an API spec, Strix copies it into the agent's workspace and authorizes the base URLs it declares (including those resolved from a Postman environment) as in-scope hosts - so the agent reads the contract and tests the full declared surface instead of discovering endpoints by crawling. Pair the spec with the deployed base URL (e.g. `--target ./openapi.yaml --target https://api.example.com`) so the agent has a reachable host to attack.
|
||||
|
||||
<Note>
|
||||
A local directory is mounted into the sandbox live and **writable**, so the agent edits your real files (`.git` excepted). Commit or stash first.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Fetching a Postman collection by id requires `POSTMAN_API_KEY`. Add `?env=<environment-uuid>` to also pull a Postman environment, which resolves `{{baseUrl}}` / token variables the collection references (e.g. `postman://<collection-uuid>?env=<environment-uid>`).
|
||||
</Note>
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--target-list" type="string">
|
||||
@@ -128,6 +134,12 @@ strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
|
||||
# Multi-target white-box testing
|
||||
strix -t https://github.com/org/app -t https://staging.example.com
|
||||
|
||||
# API spec + live target (OpenAPI/Swagger file or Postman collection)
|
||||
strix -t ./openapi.yaml -t https://api.example.com
|
||||
|
||||
# Postman collection pulled live by id (+ optional environment)
|
||||
strix -t "postman://<collection-uuid>?env=<environment-uuid>"
|
||||
|
||||
# Targets from a file
|
||||
strix --target-list ./targets.txt
|
||||
```
|
||||
|
||||
+32
-7
@@ -33,14 +33,13 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"openai-agents[litellm]==0.14.6",
|
||||
"openai>=2.26.0,<2.45",
|
||||
"openai-agents[litellm]>=0.19.0,<0.20",
|
||||
"openai>=2.45.0,<3",
|
||||
"litellm",
|
||||
"pydantic>=2.11.3",
|
||||
"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",
|
||||
@@ -49,6 +48,7 @@ dependencies = [
|
||||
# Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks
|
||||
# the Intel macOS (macos-x86_64) release build's `uv sync --frozen`.
|
||||
"cryptography>=48.0.1,<49",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -68,6 +68,7 @@ dev = [
|
||||
"pyinstaller>=6.17.0; python_version >= '3.12' and python_version < '3.15'",
|
||||
"pytest>=8.3",
|
||||
"pytest-asyncio>=0.24",
|
||||
"types-requests>=2.32",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
@@ -82,7 +83,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,13 +128,13 @@ module = [
|
||||
"litellm.*",
|
||||
"rich.*",
|
||||
"jinja2.*",
|
||||
"textual.*",
|
||||
"cvss.*",
|
||||
"docker.*",
|
||||
"caido_sdk_client.*",
|
||||
"pydantic_settings.*",
|
||||
"reportlab.*",
|
||||
"pypdf.*",
|
||||
"yaml.*",
|
||||
"pygments.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
@@ -217,10 +230,16 @@ 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"]
|
||||
"tests/test_disable_streaming.py" = ["N802"]
|
||||
"tests/test_tool_call_ids.py" = ["N802"]
|
||||
"tests/test_tool_call_limits.py" = ["N802", "SLF001"]
|
||||
"tests/test_stream_idle_timeout.py" = ["N802", "SLF001"]
|
||||
"tests/test_unknown_tool_recovery.py" = ["N802"]
|
||||
"tests/test_report_pdf.py" = ["S105", "S106"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
||||
@@ -275,9 +294,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
+18
-35
@@ -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=[],
|
||||
|
||||
@@ -207,6 +207,9 @@ VALIDATION REQUIREMENTS:
|
||||
- Full validation required - no assumptions
|
||||
- Demonstrate concrete impact with evidence
|
||||
- Consider business context for severity assessment — check whether the target is a demo/sandbox environment or content meant to be public, and factor that in
|
||||
- Score only the security impact demonstrated by the proof of concept. Reachability, missing authentication, scanner labels, and theoretical follow-on attacks do not by themselves justify non-None CVSS impact metrics
|
||||
- Treat public metadata, internal-looking identifiers, source maps without secrets, and transport/configuration hygiene as observations unless validation proves unauthorized restricted-data access, modification, or service disruption
|
||||
- Every non-None Confidentiality, Integrity, or Availability metric must map to explicit evidence in the report; use Scope Changed only for a demonstrated crossing of security authorities
|
||||
- Independent verification through subagent
|
||||
- Document complete attack chain
|
||||
- Keep going until you find something that matters
|
||||
|
||||
+13
-14
@@ -24,6 +24,8 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
from strix.utils.secret_files import write_secret_text
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@@ -67,14 +69,7 @@ def _read_store() -> dict[str, Any]:
|
||||
|
||||
|
||||
def _write_store(data: dict[str, Any]) -> None:
|
||||
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = AUTH_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.chmod(0o600)
|
||||
tmp.replace(AUTH_PATH)
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.chmod(0o600)
|
||||
write_secret_text(AUTH_PATH, json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def read_record() -> dict[str, Any] | None:
|
||||
@@ -221,19 +216,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
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -12,6 +11,7 @@ from typing import TYPE_CHECKING, Any
|
||||
from pydantic import AliasChoices, BaseModel
|
||||
|
||||
from strix.config.settings import Settings
|
||||
from strix.utils.secret_files import write_secret_text
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -71,9 +71,7 @@ def persist_current() -> None:
|
||||
env_block[alias.upper()] = value
|
||||
break
|
||||
|
||||
target.write_text(json.dumps({"env": env_block}, indent=2), encoding="utf-8")
|
||||
with contextlib.suppress(OSError):
|
||||
target.chmod(0o600)
|
||||
write_secret_text(target, json.dumps({"env": env_block}, indent=2))
|
||||
|
||||
|
||||
def _aliases_for(finfo: FieldInfo) -> list[str]:
|
||||
|
||||
+194
-7
@@ -2,11 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from agents import (
|
||||
set_default_openai_api,
|
||||
@@ -24,12 +27,19 @@ from agents.retry import (
|
||||
RetryPolicyContext,
|
||||
retry_policies,
|
||||
)
|
||||
from openai.types.responses import Response, ResponseCompletedEvent
|
||||
from openai.types.responses import (
|
||||
Response,
|
||||
ResponseCompletedEvent,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseOutputItemDoneEvent,
|
||||
)
|
||||
from openai.types.responses.response_usage import ResponseUsage
|
||||
from openai.types.shared import Reasoning
|
||||
|
||||
from strix.config import codex
|
||||
from strix.config.loader import load_settings
|
||||
from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_input
|
||||
from strix.config.tool_call_limits import TurnToolCallLimiter
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -48,6 +58,9 @@ if TYPE_CHECKING:
|
||||
from strix.config.settings import LlmSettings, ReasoningEffort, Settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
|
||||
"""Per-request model timeout; a plain float so ``ModelSettings.to_json_dict()`` stays serializable.""" # noqa: E501
|
||||
if not timeout_s or timeout_s <= 0:
|
||||
@@ -229,6 +242,170 @@ class _NonStreamingModel(Model):
|
||||
yield _completed_stream_event(response, getattr(self._inner, "model", None))
|
||||
|
||||
|
||||
class _TurnGuardModel(Model):
|
||||
"""Keep one turn from corrupting the conversation or running away.
|
||||
|
||||
Tool-call ids: providers that number calls per turn (``exec_command:0``,
|
||||
...) restart the counter each turn, so the same id eventually appears twice
|
||||
in one conversation and strict providers reject every subsequent request.
|
||||
Ids that collide with the history are rewritten before the turn is
|
||||
recorded, and already-corrupted histories are repaired on the way out.
|
||||
|
||||
Tool-call volume: a degenerate response can queue hundreds of calls that
|
||||
the run loop then honours one by one. Only the first
|
||||
``LLM_MAX_TOOL_CALLS_PER_TURN`` calls of a response are kept.
|
||||
|
||||
Stalled streams: a turn that emits a few tokens and then goes silent is
|
||||
not covered by the request timeout, which resets on any byte (keepalives
|
||||
included). ``LLM_STREAM_IDLE_TIMEOUT`` bounds the gap between events so the
|
||||
turn fails instead of hanging, and the existing retry path replays it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inner: Model,
|
||||
*,
|
||||
max_tool_calls_per_turn: int = 0,
|
||||
stream_idle_timeout: float = 0.0,
|
||||
) -> None:
|
||||
self._inner = inner
|
||||
self._max_tool_calls_per_turn = max_tool_calls_per_turn
|
||||
self._stream_idle_timeout = stream_idle_timeout
|
||||
|
||||
def _limiter(self) -> TurnToolCallLimiter:
|
||||
return TurnToolCallLimiter(self._max_tool_calls_per_turn)
|
||||
|
||||
def _log_dropped(self, limiter: TurnToolCallLimiter) -> None:
|
||||
if limiter.dropped:
|
||||
logger.warning(
|
||||
"dropped %d tool call(s) past the per-response limit of %d",
|
||||
limiter.dropped,
|
||||
self._max_tool_calls_per_turn,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._inner.close()
|
||||
|
||||
def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None:
|
||||
return self._inner.get_retry_advice(request)
|
||||
|
||||
async def get_response(
|
||||
self,
|
||||
system_instructions: str | None,
|
||||
input: str | list[TResponseInputItem], # noqa: A002
|
||||
model_settings: ModelSettings,
|
||||
tools: list[Tool],
|
||||
output_schema: AgentOutputSchemaBase | None,
|
||||
handoffs: list[Handoff],
|
||||
tracing: ModelTracing,
|
||||
*,
|
||||
previous_response_id: str | None,
|
||||
conversation_id: str | None,
|
||||
prompt: ResponsePromptParam | None,
|
||||
) -> ModelResponse:
|
||||
sanitized = dedupe_input(input)
|
||||
rewriter = TurnCallIdRewriter(sanitized)
|
||||
response = await self._inner.get_response(
|
||||
system_instructions,
|
||||
cast("str | list[TResponseInputItem]", sanitized),
|
||||
model_settings,
|
||||
tools,
|
||||
output_schema,
|
||||
handoffs,
|
||||
tracing,
|
||||
previous_response_id=previous_response_id,
|
||||
conversation_id=conversation_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
limiter = self._limiter()
|
||||
response.output = limiter.filter_items(rewriter.rewrite_items(list(response.output)))
|
||||
self._log_dropped(limiter)
|
||||
return response
|
||||
|
||||
async def stream_response(
|
||||
self,
|
||||
system_instructions: str | None,
|
||||
input: str | list[TResponseInputItem], # noqa: A002
|
||||
model_settings: ModelSettings,
|
||||
tools: list[Tool],
|
||||
output_schema: AgentOutputSchemaBase | None,
|
||||
handoffs: list[Handoff],
|
||||
tracing: ModelTracing,
|
||||
*,
|
||||
previous_response_id: str | None,
|
||||
conversation_id: str | None,
|
||||
prompt: ResponsePromptParam | None,
|
||||
) -> AsyncIterator[TResponseStreamEvent]:
|
||||
sanitized = dedupe_input(input)
|
||||
rewriter = TurnCallIdRewriter(sanitized)
|
||||
limiter = self._limiter()
|
||||
stream = self._inner.stream_response(
|
||||
system_instructions,
|
||||
cast("str | list[TResponseInputItem]", sanitized),
|
||||
model_settings,
|
||||
tools,
|
||||
output_schema,
|
||||
handoffs,
|
||||
tracing,
|
||||
previous_response_id=previous_response_id,
|
||||
conversation_id=conversation_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
async for event in _with_idle_timeout(stream, self._stream_idle_timeout):
|
||||
guarded = _guard_event(event, rewriter, limiter)
|
||||
if guarded is not None:
|
||||
yield guarded
|
||||
self._log_dropped(limiter)
|
||||
|
||||
|
||||
async def _aclose(stream: AsyncIterator[TResponseStreamEvent]) -> None:
|
||||
if isinstance(stream, AsyncGenerator):
|
||||
with contextlib.suppress(Exception):
|
||||
await stream.aclose()
|
||||
|
||||
|
||||
async def _with_idle_timeout(
|
||||
stream: AsyncIterator[TResponseStreamEvent], timeout: float
|
||||
) -> AsyncIterator[TResponseStreamEvent]:
|
||||
if timeout <= 0:
|
||||
async for event in stream:
|
||||
yield event
|
||||
return
|
||||
|
||||
iterator = stream.__aiter__()
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.wait_for(iterator.__anext__(), timeout)
|
||||
except StopAsyncIteration:
|
||||
return
|
||||
except TimeoutError:
|
||||
await _aclose(stream)
|
||||
message = f"model stream produced no event for {timeout:.0f}s"
|
||||
logger.warning("%s; abandoning the turn", message)
|
||||
raise TimeoutError(message) from None
|
||||
yield event
|
||||
|
||||
|
||||
def _guard_event(
|
||||
event: TResponseStreamEvent, rewriter: TurnCallIdRewriter, limiter: TurnToolCallLimiter
|
||||
) -> TResponseStreamEvent | None:
|
||||
if isinstance(event, ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent):
|
||||
rewritten = rewriter.rewrite_item(event.item)
|
||||
if not limiter.allow(rewritten):
|
||||
return None
|
||||
if rewritten is not event.item:
|
||||
return event.model_copy(update={"item": rewritten})
|
||||
return event
|
||||
if isinstance(event, ResponseCompletedEvent):
|
||||
original = list(event.response.output)
|
||||
output = limiter.filter_items(rewriter.rewrite_items(original))
|
||||
if output != original:
|
||||
return event.model_copy(
|
||||
update={"response": event.response.model_copy(update={"output": output})}
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
def _completed_stream_event(
|
||||
model_response: ModelResponse, model_name: object | None
|
||||
) -> TResponseStreamEvent:
|
||||
@@ -294,19 +471,29 @@ class StrixProvider(MultiProvider):
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
llm = load_settings().llm
|
||||
slug = codex.subscription_model(model_name)
|
||||
idle_timeout = float(llm.stream_idle_timeout)
|
||||
if slug:
|
||||
# The ChatGPT subscription backend is always streamed; it has no
|
||||
# non-streaming mode to fall back to, so LLM_DISABLE_STREAMING
|
||||
# does not apply here.
|
||||
return _CodexResponsesModel(
|
||||
model: Model = _CodexResponsesModel(
|
||||
slug,
|
||||
codex.get_subscription_client(),
|
||||
reasoning_effort=llm.reasoning_effort,
|
||||
)
|
||||
model = super().get_model(model_name)
|
||||
if llm.disable_streaming:
|
||||
return _NonStreamingModel(model)
|
||||
return model
|
||||
else:
|
||||
model = super().get_model(model_name)
|
||||
if llm.disable_streaming:
|
||||
model = _NonStreamingModel(model)
|
||||
# The wrapper emits its single event only once the whole request
|
||||
# is done, so an idle gap is meaningless here; the request
|
||||
# timeout bounds it instead.
|
||||
idle_timeout = 0.0
|
||||
return _TurnGuardModel(
|
||||
model,
|
||||
max_tool_calls_per_turn=llm.max_tool_calls_per_turn,
|
||||
stream_idle_timeout=idle_timeout,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||
|
||||
@@ -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(
|
||||
@@ -55,6 +57,12 @@ class LlmSettings(BaseSettings):
|
||||
alias="LLM_DISABLE_STREAMING",
|
||||
)
|
||||
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
||||
stream_idle_timeout: int = Field(default=300, ge=0, alias="LLM_STREAM_IDLE_TIMEOUT")
|
||||
max_tool_calls_per_turn: int = Field(
|
||||
default=32,
|
||||
ge=0,
|
||||
alias="LLM_MAX_TOOL_CALLS_PER_TURN",
|
||||
)
|
||||
|
||||
|
||||
class DedupeSettings(BaseSettings):
|
||||
@@ -65,11 +73,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 +123,16 @@ 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,
|
||||
)
|
||||
postman_api_key: str | None = Field(
|
||||
default=None,
|
||||
alias="POSTMAN_API_KEY",
|
||||
repr=False,
|
||||
)
|
||||
|
||||
|
||||
class ViewerSettings(BaseSettings):
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Keep tool-call ids unique within a conversation.
|
||||
|
||||
Some providers return per-turn tool-call ids (``exec_command:0``,
|
||||
``exec_command:1``, ...) whose counter restarts on every turn. Once the same
|
||||
id appears twice in one conversation, the request payload has two assistant
|
||||
tool calls sharing an id and strict providers reject the whole turn, which
|
||||
permanently kills the agent because the malformed history is replayed on
|
||||
every retry. Rewriting duplicates to fresh unique ids keeps the history
|
||||
valid for any provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
|
||||
|
||||
def new_call_id() -> str:
|
||||
return f"call_{uuid4().hex}"
|
||||
|
||||
|
||||
def collect_call_ids(items: list[Any]) -> set[str]:
|
||||
used: set[str] = set()
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
call_id = item.get("call_id")
|
||||
if isinstance(call_id, str):
|
||||
used.add(call_id)
|
||||
elif isinstance(item, ResponseFunctionToolCall):
|
||||
used.add(item.call_id)
|
||||
return used
|
||||
|
||||
|
||||
def dedupe_history_call_ids(items: list[Any]) -> tuple[list[Any], bool]:
|
||||
"""Rewrite duplicate call ids in a conversation history.
|
||||
|
||||
Outputs are paired with their call by order, so parallel calls that share
|
||||
an id keep answering the right call after the rewrite.
|
||||
"""
|
||||
used: set[str] = set()
|
||||
pending: dict[str, deque[str]] = defaultdict(deque)
|
||||
rebuilt: list[Any] = []
|
||||
changed = False
|
||||
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
rebuilt.append(item)
|
||||
continue
|
||||
call_id = item.get("call_id")
|
||||
if not isinstance(call_id, str):
|
||||
rebuilt.append(item)
|
||||
continue
|
||||
|
||||
kind = item.get("type")
|
||||
if kind == "function_call":
|
||||
effective = call_id
|
||||
if call_id in used:
|
||||
effective = new_call_id()
|
||||
item = {**item, "call_id": effective} # noqa: PLW2901
|
||||
changed = True
|
||||
used.add(effective)
|
||||
pending[call_id].append(effective)
|
||||
elif kind == "function_call_output":
|
||||
queue = pending.get(call_id)
|
||||
if queue:
|
||||
effective = queue.popleft()
|
||||
if effective != call_id:
|
||||
item = {**item, "call_id": effective} # noqa: PLW2901
|
||||
changed = True
|
||||
rebuilt.append(item)
|
||||
|
||||
return rebuilt, changed
|
||||
|
||||
|
||||
def dedupe_input(model_input: str | list[Any]) -> str | list[Any]:
|
||||
if isinstance(model_input, str):
|
||||
return model_input
|
||||
rebuilt, changed = dedupe_history_call_ids(model_input)
|
||||
return rebuilt if changed else model_input
|
||||
|
||||
|
||||
class TurnCallIdRewriter:
|
||||
"""Rewrite a single turn's tool-call ids that collide with the history.
|
||||
|
||||
A turn's items surface several times (streamed item events, then the
|
||||
completed response), so the same original id must always map to the same
|
||||
replacement within the turn.
|
||||
"""
|
||||
|
||||
def __init__(self, model_input: str | list[Any]) -> None:
|
||||
self._used = set() if isinstance(model_input, str) else collect_call_ids(model_input)
|
||||
self._remap: dict[str, str] = {}
|
||||
self._settled: set[str] = set()
|
||||
|
||||
def rewrite_item(self, item: Any) -> Any:
|
||||
if not isinstance(item, ResponseFunctionToolCall):
|
||||
return item
|
||||
original = item.call_id
|
||||
if original in self._settled:
|
||||
return item
|
||||
replacement = self._remap.get(original)
|
||||
if replacement is None:
|
||||
if original not in self._used:
|
||||
self._used.add(original)
|
||||
self._settled.add(original)
|
||||
return item
|
||||
replacement = new_call_id()
|
||||
self._remap[original] = replacement
|
||||
self._used.add(replacement)
|
||||
self._settled.add(replacement)
|
||||
return item.model_copy(update={"call_id": replacement})
|
||||
|
||||
def rewrite_items(self, items: list[Any]) -> list[Any]:
|
||||
return [self.rewrite_item(item) for item in items]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Bound how many tool calls one assistant response may queue.
|
||||
|
||||
A degenerate generation can emit hundreds or thousands of tool calls in a
|
||||
single response — typically a poll/wait loop the model writes out ahead of
|
||||
time instead of issuing one call and yielding. The run loop honours all of
|
||||
them, so the agent stops reacting to anything for hours. Keeping only the
|
||||
first ``limit`` calls of a response bounds that blast radius; the model sees
|
||||
their results on the next turn and can reconsider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
|
||||
|
||||
class TurnToolCallLimiter:
|
||||
"""Decide, once per call, whether a turn's tool call is within the limit."""
|
||||
|
||||
def __init__(self, limit: int) -> None:
|
||||
self._limit = limit
|
||||
self._decisions: dict[str, bool] = {}
|
||||
self._kept = 0
|
||||
self.dropped = 0
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._limit > 0
|
||||
|
||||
def allow(self, item: Any) -> bool:
|
||||
if not self.enabled or not isinstance(item, ResponseFunctionToolCall):
|
||||
return True
|
||||
decided = self._decisions.get(item.call_id)
|
||||
if decided is not None:
|
||||
return decided
|
||||
allowed = self._kept < self._limit
|
||||
if allowed:
|
||||
self._kept += 1
|
||||
else:
|
||||
self.dropped += 1
|
||||
self._decisions[item.call_id] = allowed
|
||||
return allowed
|
||||
|
||||
def filter_items(self, items: list[Any]) -> list[Any]:
|
||||
return [item for item in items if self.allow(item)]
|
||||
@@ -780,17 +780,21 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
await coordinator.set_status(agent_id, "failed", error=str(exc))
|
||||
await notify_parent_on_terminal(coordinator, agent_id, "failed")
|
||||
return None
|
||||
if not interactive:
|
||||
raise
|
||||
if isinstance(exc, MaxTurnsExceeded):
|
||||
status: Status = "stopped"
|
||||
elif isinstance(exc, UserError | AgentsException | APIError):
|
||||
status = "failed"
|
||||
else:
|
||||
status = "crashed"
|
||||
logger.exception("agent run failed for %s; parking as %s", agent_id, status)
|
||||
logger.exception("agent run failed for %s; marking %s", agent_id, status)
|
||||
# Settle the status and wake the parent before the exception unwinds a
|
||||
# non-interactive agent's task: a child that dies still owes its parent a
|
||||
# report, and the parent would otherwise wait out its timeout on a message
|
||||
# the dead child can no longer send.
|
||||
await coordinator.set_status(agent_id, status, error=str(exc) or type(exc).__name__)
|
||||
await notify_parent_on_terminal(coordinator, agent_id, status)
|
||||
if not interactive:
|
||||
raise
|
||||
return None
|
||||
else:
|
||||
return cast("RunResultBase | None", stream)
|
||||
|
||||
+72
-13
@@ -33,6 +33,50 @@ def _accepts_required_tool_choice(model_name: str | None) -> bool:
|
||||
return name.startswith("openai/") or is_known_openai_bare_model(name)
|
||||
|
||||
|
||||
def _render_diff_scope(diff_scope: dict[str, Any]) -> list[str]:
|
||||
"""Render pull-request diff-scope constraints as root-task lines."""
|
||||
if not diff_scope.get("active"):
|
||||
return []
|
||||
parts: list[str] = [
|
||||
"\n\nScope Constraints:",
|
||||
"- Pull request diff-scope mode is active. Prioritize changed files "
|
||||
"and use other files only for context.",
|
||||
]
|
||||
for repo_scope in diff_scope.get("repos", []) or []:
|
||||
label = repo_scope.get("workspace_subdir") or repo_scope.get("source_path") or "repository"
|
||||
changed = repo_scope.get("analyzable_files_count", 0)
|
||||
deleted = repo_scope.get("deleted_files_count", 0)
|
||||
parts.append(f"- {label}: {changed} changed file(s) in primary scope")
|
||||
if deleted:
|
||||
parts.append(f"- {label}: {deleted} deleted file(s) are context-only")
|
||||
return parts
|
||||
|
||||
|
||||
def _render_api_spec(details: dict[str, Any]) -> list[str]:
|
||||
"""Render an API spec target as root-task lines.
|
||||
|
||||
The spec itself is in the workspace, so the task points at the file and lets
|
||||
the agent read the contract rather than restating a parsed summary of it.
|
||||
"""
|
||||
title = details.get("spec_title") or details.get("target_spec", "API")
|
||||
workspace_path = details.get("workspace_path", "")
|
||||
lines = [
|
||||
f"- {title} ({details.get('spec_format', 'api')} specification"
|
||||
+ (f", available at: {workspace_path}" if workspace_path else "")
|
||||
+ ")"
|
||||
]
|
||||
if base_urls := details.get("base_urls") or []:
|
||||
lines.append(" - Base URL(s): " + ", ".join(base_urls))
|
||||
lines.append(
|
||||
" - Read the specification and test every operation it declares, using "
|
||||
"its declared parameters, request bodies, and auth. Endpoints in the "
|
||||
"specification are in scope even when nothing links to them. Load the "
|
||||
"`api_spec_testing` skill for the methodology, or spawn a specialist "
|
||||
"with it."
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def build_root_task(scan_config: dict[str, Any]) -> str:
|
||||
targets = scan_config.get("targets", []) or []
|
||||
diff_scope = scan_config.get("diff_scope") or {}
|
||||
@@ -43,6 +87,7 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
|
||||
"Local Codebases": [],
|
||||
"URLs": [],
|
||||
"IP Addresses": [],
|
||||
"API Specifications": [],
|
||||
}
|
||||
|
||||
for target in targets:
|
||||
@@ -68,6 +113,8 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
|
||||
sections["URLs"].append(f"- {details.get('target_url', '')}")
|
||||
elif ttype == "ip_address":
|
||||
sections["IP Addresses"].append(f"- {details.get('target_ip', '')}")
|
||||
elif ttype == "api_spec":
|
||||
sections["API Specifications"].extend(_render_api_spec(details))
|
||||
|
||||
parts: list[str] = []
|
||||
for label, items in sections.items():
|
||||
@@ -75,21 +122,24 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
|
||||
parts.append(f"\n\n{label}:")
|
||||
parts.extend(items)
|
||||
|
||||
if diff_scope.get("active"):
|
||||
parts.append("\n\nScope Constraints:")
|
||||
# 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(
|
||||
"- Pull request diff-scope mode is active. Prioritize changed files "
|
||||
"and use other files only for context.",
|
||||
f"- {workspace_mount} (available at: {workspace_path}; "
|
||||
"this is the user's real directory, mounted live and writable — "
|
||||
".git/.agents/.codex are read-only)"
|
||||
)
|
||||
for repo_scope in diff_scope.get("repos", []) or []:
|
||||
label = (
|
||||
repo_scope.get("workspace_subdir") or repo_scope.get("source_path") or "repository"
|
||||
)
|
||||
changed = repo_scope.get("analyzable_files_count", 0)
|
||||
deleted = repo_scope.get("deleted_files_count", 0)
|
||||
parts.append(f"- {label}: {changed} changed file(s) in primary scope")
|
||||
if deleted:
|
||||
parts.append(f"- {label}: {deleted} deleted file(s) are context-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."
|
||||
)
|
||||
|
||||
parts.extend(_render_diff_scope(diff_scope))
|
||||
|
||||
task = " ".join(parts)
|
||||
if user_instructions:
|
||||
@@ -104,6 +154,7 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
|
||||
"local_code": "target_path",
|
||||
"web_application": "target_url",
|
||||
"ip_address": "target_ip",
|
||||
"api_spec": "target_spec",
|
||||
}
|
||||
for target in scan_config.get("targets", []) or []:
|
||||
ttype = target.get("type", "unknown")
|
||||
@@ -117,6 +168,14 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
|
||||
{"type": ttype, "value": value, "workspace_path": workspace_path},
|
||||
)
|
||||
|
||||
# An API spec authorizes the hosts it declares as in-scope web targets
|
||||
# so the agent can exercise every endpoint without expanding scope.
|
||||
if ttype == "api_spec":
|
||||
authorized.extend(
|
||||
{"type": "web_application", "value": base_url, "workspace_path": ""}
|
||||
for base_url in details.get("base_urls") or []
|
||||
)
|
||||
|
||||
return {
|
||||
"scope_source": "system_scan_config",
|
||||
"authorization_source": "strix_platform_verified_targets",
|
||||
|
||||
@@ -268,6 +268,9 @@ async def run_strix_scan(
|
||||
model_settings=model_settings,
|
||||
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
|
||||
trace_include_sensitive_data=False,
|
||||
# A hallucinated tool name is a recoverable model mistake, not a scan-ending
|
||||
# error: hand it back as a tool result so the agent can correct itself.
|
||||
tool_not_found_behavior="return_error_to_model",
|
||||
)
|
||||
hooks = ReportUsageHooks(
|
||||
model=resolved_model,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
"""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
|
||||
|
||||
# API spec test (OpenAPI/Swagger file or Postman collection export)
|
||||
strix --target ./openapi.yaml --target https://api.example.com
|
||||
strix --target ./collection.postman_collection.json
|
||||
|
||||
# Postman collection pulled live by id (needs POSTMAN_API_KEY); optional environment
|
||||
strix --target postman://<collection-uuid> --target https://api.example.com
|
||||
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
|
||||
|
||||
# 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, IP address, "
|
||||
"an API spec file (OpenAPI/Swagger .json/.yaml or a Postman collection export), or a "
|
||||
"Postman collection by id (postman://<collection-uuid>[?env=<environment-uuid>], needs "
|
||||
"POSTMAN_API_KEY). 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 <run_name> 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
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
+111
-721
@@ -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 <run_name> 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""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, Any
|
||||
|
||||
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,
|
||||
stage_api_specs,
|
||||
write_fetched_collection,
|
||||
)
|
||||
from strix.telemetry import posthog, scarf
|
||||
from strix.utils.api_spec import (
|
||||
SpecParseError,
|
||||
fetch_postman_collection,
|
||||
fetch_postman_environment,
|
||||
load_spec,
|
||||
spec_base_urls,
|
||||
spec_title,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
if target_type == "api_spec":
|
||||
_resolve_api_spec(target, target_dict)
|
||||
|
||||
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 _resolve_api_spec(target: str, details: dict[str, Any]) -> None:
|
||||
"""Read the spec up front so bad input fails before the run starts.
|
||||
|
||||
Records the declared base URLs (the only thing scope authorization can take
|
||||
from a spec) and, for a ``postman://`` target, downloads the collection to a
|
||||
local file so the sandbox never needs the Postman API key.
|
||||
"""
|
||||
try:
|
||||
if details.get("source") == "postman_api":
|
||||
collection_uid = str(details["collection_uid"])
|
||||
api_key = load_settings().integrations.postman_api_key or ""
|
||||
raw = fetch_postman_collection(collection_uid, api_key)
|
||||
environment_uid = str(details.get("environment_uid") or "")
|
||||
extra_variables = (
|
||||
fetch_postman_environment(environment_uid, api_key) if environment_uid else None
|
||||
)
|
||||
details["target_spec"] = write_fetched_collection(raw, collection_uid)
|
||||
else:
|
||||
raw = load_spec(str(details["target_spec"]))
|
||||
extra_variables = None
|
||||
base_urls = spec_base_urls(raw, extra_variables=extra_variables)
|
||||
except SpecParseError as exc:
|
||||
raise ValueError(f"Invalid API spec '{target}': {exc}") from None
|
||||
|
||||
details["spec_title"] = spec_title(raw)
|
||||
details["base_urls"] = base_urls
|
||||
|
||||
|
||||
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)
|
||||
args.local_sources.extend(stage_api_specs(args.targets_info, args.run_name))
|
||||
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)
|
||||
@@ -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"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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.
|
||||
@@ -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() }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
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).
|
||||
// The log budgets rows by entry, so a message is flattened to one line first: a
|
||||
// wrapped exception would otherwise render as several rows and push the launch
|
||||
// column past the bottom of the terminal.
|
||||
func (m *Model) setupMsg(text string, style lipgloss.Style) {
|
||||
m.setupLogAppend(style.Render(flattenStatus(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("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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A wrapped exception is several lines. The log budgets rows by entry, so it has
|
||||
// to become one row or the launch column grows past the terminal.
|
||||
func TestSetupLogKeepsMultiLineErrorsToOneRow(t *testing.T) {
|
||||
model := New(nil)
|
||||
model.width, model.height = 100, 26
|
||||
model.showSplash = false
|
||||
model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{SetupMode: true, ScanState: "setup"}))
|
||||
model.setupMsg("boom\nTraceback (most recent call last):\n File \"x.py\", line 1\n raise", render.Col(red))
|
||||
model.resizeViewport()
|
||||
|
||||
if entries := len(model.setupLog); entries != 1 {
|
||||
t.Fatalf("one message became %d log entries", entries)
|
||||
}
|
||||
if strings.Contains(model.setupLog[0], "\n") {
|
||||
t.Fatalf("log entry spans rows: %q", model.setupLog[0])
|
||||
}
|
||||
lines := strings.Split(model.View(), "\n")
|
||||
if len(lines) > model.height {
|
||||
t.Fatalf("start screen is %d rows in a %d-row terminal", len(lines), model.height)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
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 = statusMessage(msg, red, " · Send message to resume", width)
|
||||
}
|
||||
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 = statusMessage(msg, red, " · Send message to resume", width)
|
||||
}
|
||||
}
|
||||
if m.errorText != "" {
|
||||
left = statusMessage(m.errorText, red, "", width-lipgloss.Width(right))
|
||||
}
|
||||
return composeStatusRow(left, right, width)
|
||||
}
|
||||
|
||||
// composeStatusRow lays the status text and the corner hint on one row exactly
|
||||
// width columns wide. A wider row would widen the whole chat column, because
|
||||
// JoinHorizontal pads every row of a block to its widest, which pushes the
|
||||
// sidebar off screen and wraps the frame.
|
||||
func composeStatusRow(left, right string, width int) string {
|
||||
if width <= 0 {
|
||||
return ""
|
||||
}
|
||||
const leading = 1 // the row is indented one column, like the panels above it
|
||||
// A terminal can be narrower than the hint itself. Drop the hint rather than
|
||||
// keep it at the cost of the status, which is the part carrying information;
|
||||
// ctrl-q works whether or not the row has room to say so.
|
||||
if lipgloss.Width(right) > 0 && width < lipgloss.Width(right)+leading+2 {
|
||||
right = ""
|
||||
}
|
||||
separator := 0
|
||||
if lipgloss.Width(right) > 0 {
|
||||
separator = 1
|
||||
}
|
||||
left = truncate(left, max(0, width-leading-lipgloss.Width(right)-separator))
|
||||
padding := max(0, width-leading-lipgloss.Width(left)-lipgloss.Width(right))
|
||||
return " " + left + strings.Repeat(" ", padding) + right
|
||||
}
|
||||
|
||||
// statusMessage fits a message and its trailing hint on the one status row. A
|
||||
// model or backend error can be a wrapped exception several lines long, so it is
|
||||
// flattened to a single line and clipped, leaving the hint readable.
|
||||
func statusMessage(message string, color lipgloss.Color, hint string, width int) string {
|
||||
styledHint := lipgloss.NewStyle().Foreground(dim).Render(hint)
|
||||
room := max(1, width-2-lipgloss.Width(styledHint))
|
||||
flat := truncate(flattenStatus(message), room)
|
||||
return lipgloss.NewStyle().Foreground(color).Render(flat) + styledHint
|
||||
}
|
||||
|
||||
// flattenStatus turns a multi-line message into one line, collapsing the runs of
|
||||
// whitespace that joining its lines leaves behind.
|
||||
func flattenStatus(message string) string {
|
||||
message = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ", "\t", " ").Replace(message)
|
||||
return strings.Join(strings.Fields(message), " ")
|
||||
}
|
||||
|
||||
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, "")
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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("Introduced By", render.StringValue(dep["introduced_by"]))
|
||||
field("Dependency Chain", render.StringValue(dep["dependency_path"]))
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
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("Introduced By", render.StringValue(dep["introduced_by"]))
|
||||
field("Dependency Chain", render.StringValue(dep["dependency_path"]))
|
||||
}
|
||||
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()).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user