feat(tui): replace Textual with a Go/Bubble Tea interface (#941)

This commit is contained in:
oyasumi
2026-08-03 19:23:07 -07:00
committed by GitHub
parent a51ca18666
commit 5bb9fe896b
128 changed files with 16229 additions and 6562 deletions
+22
View File
@@ -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:
+5
View File
@@ -93,3 +93,8 @@ Thumbs.db
schema.graphql
.opencode/
# Root-only local data and reference checkouts
/.benchmarks/
/references/
/strix_runs_main/
+1
View File
@@ -20,6 +20,7 @@ repos:
pydantic,
fastapi,
pytest,
hatchling,
"openai-agents[litellm]==0.14.6",
]
args: [--install-types, --non-interactive]
+17
View File
@@ -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)
+20 -1
View File
@@ -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 ./...
+1 -1
View File
@@ -311,7 +311,7 @@ Have questions? Found a bug? Want to contribute? **[Join our Discord!](https://d
## Acknowledgements
Strix builds on the incredible work of open-source projects like [LiteLLM](https://github.com/BerriAI/litellm), [Caido](https://github.com/caido/caido), [Nuclei](https://github.com/projectdiscovery/nuclei), [Playwright](https://github.com/microsoft/playwright), and [Textual](https://github.com/Textualize/textual). Huge thanks to their maintainers!
Strix builds on the incredible work of open-source projects like [LiteLLM](https://github.com/BerriAI/litellm), [Caido](https://github.com/caido/caido), [Nuclei](https://github.com/projectdiscovery/nuclei), [Playwright](https://github.com/microsoft/playwright), and [Bubble Tea](https://github.com/charmbracelet/bubbletea). Huge thanks to their maintainers!
> [!WARNING]
+17
View File
@@ -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:
+23 -5
View File
@@ -40,7 +40,6 @@ dependencies = [
"pydantic-settings>=2.13.0",
"rich",
"docker>=7.1.0",
"textual>=6.0.0",
"requests>=2.32.0",
"cvss>=3.2",
"caido-sdk-client>=0.2.0",
@@ -82,7 +81,19 @@ packages = ["strix"]
# The prebuilt viewer bundle under strix/interface/viewer/static/ ships automatically
# (hatchling includes non-.py files under the package). The Vite SOURCE lives
# under the package dir too (strix/interface/viewer/frontend/) but must never ship in the wheel.
exclude = ["strix/interface/viewer/frontend", "strix/interface/viewer/frontend/**"]
exclude = [
"strix/interface/viewer/frontend",
"strix/interface/viewer/frontend/**",
# Go TUI SOURCE lives under the package dir but must never ship in the wheel;
# the compiled sidecar is force-included as strix/bin/strix-tui instead.
"strix/interface/tui/cmd/**",
"strix/interface/tui/internal/**",
"strix/interface/tui/go.mod",
"strix/interface/tui/go.sum",
]
[tool.hatch.build.targets.wheel.hooks.custom]
path = "scripts/tui_sidecar_hook.py"
# ============================================================================
# Type Checking Configuration
@@ -115,7 +126,6 @@ module = [
"litellm.*",
"rich.*",
"jinja2.*",
"textual.*",
"cvss.*",
"docker.*",
"caido_sdk_client.*",
@@ -217,6 +227,8 @@ ignore = [
# args they intentionally ignore.
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
"tests/test_codex_auth.py" = ["S105", "S106", "SLF001"]
# Hatchling loads the build hook by path, not as an importable package.
"scripts/tui_sidecar_hook.py" = ["INP001"]
# Stdlib HTTP handler overrides (do_GET/do_POST).
"strix/interface/auth_cli.py" = ["N802"]
"tests/test_codex_streaming.py" = ["N802"]
@@ -275,9 +287,15 @@ ignore = [
# CLI / TUI / main keep extensive lazy imports + broad exception
# swallows for resilience around terminal-rendering errors.
"strix/interface/cli.py" = ["BLE001", "PLC0415"]
"strix/interface/tui/app.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915", "SIM105"]
"strix/interface/scan_setup.py" = ["PLC0415"]
"strix/interface/main.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915"]
"strix/interface/tui/renderers/agent_message_renderer.py" = ["PLC0415"]
"strix/interface/cli_args.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915"]
"strix/interface/environment.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915"]
# The Go TUI runtime and backend controller import interface modules lazily so
# the sidecar entry point stays fast and avoids circular imports.
"strix/interface/interactive.py" = ["PLC0415"]
"strix/interface/tui/runtime.py" = ["PLC0415"]
"strix/interface/tui/backend/controller.py" = ["PLC0415"]
[tool.ruff.lint.isort]
force-single-line = false
+14
View File
@@ -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
+58
View File
@@ -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
View File
@@ -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=[],
+10 -6
View File
@@ -221,19 +221,23 @@ def _first(query: dict[str, list[str]], key: str) -> str | None:
def _post_form(payload: dict[str, str]) -> dict[str, Any]:
detail = ""
try:
response = requests.post(
with requests.post(
TOKEN_URL,
data=payload,
headers={"Accept": "application/json"},
timeout=_TOKEN_TIMEOUT,
)
) as response:
status_code = response.status_code
body = response.content
if status_code >= 400:
detail = response.text[:300]
except requests.RequestException as exc:
raise CodexAuthError("unavailable", str(exc)) from exc
if response.status_code >= 400:
detail = response.text[:300]
raise CodexAuthError("token_http_error", f"HTTP {response.status_code}: {detail}")
data = json.loads(response.content or b"{}")
if status_code >= 400:
raise CodexAuthError("token_http_error", f"HTTP {status_code}: {detail}")
data = json.loads(body or b"{}")
if not isinstance(data, dict):
raise CodexAuthError("bad_response", "token endpoint returned non-object")
return data
+9 -2
View File
@@ -26,6 +26,7 @@ class LlmSettings(BaseSettings):
api_key: str | None = Field(
default=None,
validation_alias=AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"),
repr=False,
)
api_base: str | None = Field(
default=None,
@@ -40,6 +41,7 @@ class LlmSettings(BaseSettings):
extra_headers: dict[str, str] | None = Field(
default=None,
alias="LLM_EXTRA_HEADERS",
repr=False,
)
reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT")
force_required_tool_choice: bool = Field(
@@ -65,11 +67,12 @@ class DedupeSettings(BaseSettings):
default=None,
alias="STRIX_DEDUPE_REASONING_EFFORT",
)
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY")
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY", repr=False)
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
extra_headers: dict[str, str] | None = Field(
default=None,
alias="DEDUPE_LLM_EXTRA_HEADERS",
repr=False,
)
@@ -114,7 +117,11 @@ class TelemetrySettings(BaseSettings):
class IntegrationSettings(BaseSettings):
model_config = _BASE_CONFIG
perplexity_api_key: str | None = Field(default=None, alias="PERPLEXITY_API_KEY")
perplexity_api_key: str | None = Field(
default=None,
alias="PERPLEXITY_API_KEY",
repr=False,
)
class ViewerSettings(BaseSettings):
+17
View File
@@ -75,6 +75,23 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
parts.append(f"\n\n{label}:")
parts.extend(items)
# A workspace mount is a directory to work in, not an asset to test. It is
# listed apart from the targets so it never reads as scope.
if workspace_mount := scan_config.get("workspace_mount") or "":
subdir = scan_config.get("workspace_subdir") or ""
workspace_path = f"/workspace/{subdir}" if subdir else "/workspace"
parts.append("\n\nWorking Directory:")
parts.append(
f"- {workspace_mount} (available at: {workspace_path}; "
"this is the user's real directory, mounted live and writable — "
".git/.agents/.codex are read-only)"
)
parts.append(
"- No scan target was set. This directory is where you work, not a "
"target to assess: the instructions below are the only source of "
"truth for what to do."
)
if diff_scope.get("active"):
parts.append("\n\nScope Constraints:")
parts.append(
-697
View File
@@ -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;
}
+369
View File
@@ -0,0 +1,369 @@
"""Command-line argument parsing for the ``strix`` scan entrypoint."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from strix.config import apply_config_override
from strix.config.settings import DEFAULT_MAX_TURNS
from strix.core.paths import run_dir_for, runtime_state_dir
from strix.interface.scan_setup import attach_workspace_mount, build_targets_info
from strix.interface.update_check import self_update
from strix.interface.utils import (
check_mountable_dir,
collect_local_sources,
validate_config_file,
)
def get_version() -> str:
try:
from importlib.metadata import version
return version("strix-agent")
except Exception:
return "unknown"
def _positive_budget(value: str) -> float:
try:
budget = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"invalid float value: {value!r}") from exc
import math
if not math.isfinite(budget) or budget <= 0:
raise argparse.ArgumentTypeError("must be a finite number greater than 0")
return budget
def _positive_int(value: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") from exc
if parsed <= 0:
raise argparse.ArgumentTypeError("must be an integer greater than 0")
return parsed
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Web application penetration test
strix --target https://example.com
# GitHub repository analysis
strix --target https://github.com/user/repo
strix --target git@github.com:user/repo.git
# Local code analysis
strix --target ./my-project
# Domain penetration test
strix --target example.com
# IP address penetration test
strix --target 192.168.1.42
# Multiple targets (e.g., white-box testing with source and deployed app)
strix --target https://github.com/user/repo --target https://example.com
strix --target ./my-project --target https://staging.example.com --target https://prod.example.com
# Targets from a file, one target per non-empty, non-comment line
strix --target-list ./targets.txt
# Custom instructions (inline)
strix --target example.com --instruction "Focus on authentication vulnerabilities"
# Custom instructions (from file)
strix --target example.com --instruction-file ./instructions.txt
strix --target https://app.com --instruction-file /path/to/detailed_instructions.md
""",
)
parser.add_argument(
"-v",
"--version",
action="version",
version=f"strix {get_version()}",
)
parser.add_argument(
"--update",
action="store_true",
help="Update strix to the latest version and exit. Self-updates the "
"standalone binary install; for pip/pipx/uv installs, prints the "
"matching upgrade command instead.",
)
parser.add_argument(
"-t",
"--target",
type=str,
action="append",
help="Target to test (URL, repository, local directory path, domain name, or IP address). "
"Local directories are mounted into the sandbox writable. "
"Can be specified multiple times for multi-target scans. "
"Fresh runs require --target or --target-list.",
)
parser.add_argument(
"--target-list",
type=str,
action="append",
metavar="PATH",
help="Path to a file containing targets, one per non-empty, non-comment line. "
"Can be specified multiple times and combined with --target.",
)
parser.add_argument(
"--instruction",
type=str,
help="Custom instructions for the penetration test. This can be "
"specific vulnerability types to focus on (e.g., 'Focus on IDOR and XSS'), "
"testing approaches (e.g., 'Perform thorough authentication testing'), "
"test credentials (e.g., 'Use the following credentials to access the app: "
"admin:password123'), "
"or areas of interest (e.g., 'Check login API endpoint for security issues').",
)
parser.add_argument(
"--instruction-file",
type=str,
help="Path to a file containing detailed custom instructions for the penetration test. "
"Use this option when you have lengthy or complex instructions saved in a file "
"(e.g., '--instruction-file ./detailed_instructions.txt').",
)
parser.add_argument(
"-n",
"--non-interactive",
action="store_true",
help=(
"Run in non-interactive mode (no TUI, exits on completion). "
"Default is interactive mode with TUI."
),
)
parser.add_argument(
"-m",
"--scan-mode",
type=str,
choices=["quick", "standard", "deep"],
default="deep",
help=(
"Scan mode: "
"'quick' for fast CI/CD checks, "
"'standard' for routine testing, "
"'deep' for thorough security reviews (default). "
"Default: deep."
),
)
parser.add_argument(
"--scope-mode",
type=str,
choices=["auto", "diff", "full"],
default="auto",
help=(
"Scope mode for code targets: "
"'auto' enables PR diff-scope in CI/headless runs, "
"'diff' forces changed-files scope, "
"'full' disables diff-scope."
),
)
parser.add_argument(
"--diff-base",
type=str,
help=(
"Target branch or commit to compare against (e.g., origin/main). "
"Defaults to the repository's default branch."
),
)
parser.add_argument(
"--config",
type=str,
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
)
parser.add_argument(
"--max-budget",
"--max-budget-usd",
dest="max_budget_usd",
metavar="USD",
type=_positive_budget,
default=None,
help=(
"Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached. "
"Graduated wrap-up warnings are sent to all agents as it is approached."
),
)
parser.add_argument(
"--max-turns",
dest="max_turns",
metavar="N",
type=_positive_int,
default=DEFAULT_MAX_TURNS,
help=(
"Maximum turns per agent (> 0, default %(default)s). Each agent is force-stopped "
"when it reaches this limit, with graduated wrap-up warnings as it is approached."
),
)
parser.add_argument(
"--resume",
type=str,
metavar="RUN_NAME",
help=(
"Resume a prior scan by its run name (the dir under ./strix_runs/). "
"Picks up the root + every non-terminal subagent's full LLM history "
"and agent topology. Skips fresh run-name generation."
),
)
args = parser.parse_args()
# Startup-resolved state lives alongside the parsed flags. The full schema
# is established here so downstream code reads attributes directly.
args.needs_setup = False
args.targets_info = []
args.local_sources = []
args.diff_scope = {"active": False}
args.run_name = None
if args.config:
apply_config_override(validate_config_file(args.config))
if args.update:
sys.exit(0 if self_update() else 1)
if args.instruction and args.instruction_file:
parser.error(
"Cannot specify both --instruction and --instruction-file. Use one or the other."
)
if args.instruction_file:
instruction_path = Path(args.instruction_file)
try:
with instruction_path.open(encoding="utf-8") as f:
args.instruction = f.read().strip()
if not args.instruction:
parser.error(f"Instruction file '{instruction_path}' is empty")
except Exception as e:
parser.error(f"Failed to read instruction file '{instruction_path}': {e}")
args.user_explicit_instruction = args.instruction if args.resume else None
# What the user actually asked for, kept apart from args.instruction because
# prepare_run prepends the diff-scope preamble to that. This is the text the
# transcript shows as their opening message.
args.user_instruction = args.instruction or None
if args.resume:
if args.target or args.target_list:
parser.error(
"Cannot combine --resume with --target/--target-list. "
"--resume picks up where the prior run left off, including the "
"original target list."
)
_load_resume_state(args, parser)
agents_path = runtime_state_dir(run_dir_for(args.resume)) / "agents.json"
if not agents_path.exists():
parser.error(
f"--resume {args.resume}: missing {agents_path}. The run was "
f"persisted but never reached its first agent snapshot — "
f"there's nothing to resume from. Pick a fresh --run-name "
f"or remove --resume to start over with the same targets."
)
else:
if not args.target and not args.target_list:
if args.non_interactive:
parser.error(
"the following arguments are required: -t/--target or --target-list "
"(or use --resume <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
+217
View File
@@ -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()
+38
View File
@@ -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
View File
@@ -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)
+223
View File
@@ -0,0 +1,223 @@
"""Scan bootstrap shared by the CLI entry point and the TUI setup flow.
Target resolution, run preparation, model preflight, and start-of-run
telemetry live here so ``strix.interface.main`` (the CLI) and
``strix.interface.tui.runtime`` (interactive setup) depend on one module
instead of each other. Everything raises ordinary exceptions; rendering
errors and exiting the process is the caller's job.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from strix.config import Settings, codex, load_settings
from strix.core.paths import run_dir_for
from strix.interface.utils import (
assign_workspace_subdirs,
clone_repository,
collect_local_sources,
dedupe_local_targets,
derive_local_base_name,
generate_run_name,
infer_target_type,
is_whitebox_scan,
read_target_list_file,
resolve_diff_scope_context,
rewrite_localhost_targets,
)
from strix.telemetry import posthog, scarf
if TYPE_CHECKING:
import argparse
logger = logging.getLogger(__name__)
HOST_GATEWAY_HOSTNAME = "host.docker.internal"
class ModelConnectionError(RuntimeError):
"""An ordinary model preflight failure, annotated with its model route."""
def __init__(self, model_name: str, cause: BaseException) -> None:
super().__init__(str(cause))
self.model_name = model_name
async def preflight_model_connection(
model_name: str,
*,
settings: Settings | None = None,
) -> None:
"""Verify the configured model route before starting a scan."""
from agents.models.interface import ModelTracing
from strix.config.models import StrixProvider, configure_sdk_model_defaults
from strix.core.inputs import make_model_settings
resolved_settings = load_settings() if settings is None else settings
configure_sdk_model_defaults(resolved_settings)
model = StrixProvider().get_model(model_name)
request_settings = make_model_settings(
None,
model_name=model_name,
request_timeout=resolved_settings.llm.timeout,
prompt_cache=False,
extra_headers=resolved_settings.llm.extra_headers,
)
await asyncio.wait_for(
model.get_response(
system_instructions="You are a helpful assistant.",
input="Reply with just 'OK'.",
model_settings=request_settings,
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
),
timeout=resolved_settings.llm.timeout,
)
def build_targets_info(args: argparse.Namespace) -> None:
"""Populate ``args.targets_info`` from target/target-list inputs.
Raises :class:`ValueError` with a user-facing message on any bad input so
callers can surface it via ``parser.error`` (CLI) or a console panel (home
page).
"""
args.targets_info = []
targets = list(args.target or [])
for target_list_path in args.target_list or []:
targets.extend(read_target_list_file(target_list_path))
for target in targets:
try:
target_type, target_dict = infer_target_type(target)
except ValueError as e:
raise ValueError(f"Invalid target '{target}': {e}") from None
if target_type == "local_code":
display_target = target_dict.get("target_path", target)
else:
display_target = target
args.targets_info.append(
{"type": target_type, "details": target_dict, "original": display_target}
)
args.targets_info = dedupe_local_targets(args.targets_info)
assign_workspace_subdirs(args.targets_info)
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
def prepare_run(args: argparse.Namespace) -> None:
"""Resolve the run name, clone repos, compute diff-scope, and persist state.
Shared by the CLI startup path and the interactive TUI setup phase (once the
user has supplied a target via ``/target``). Mutates *args* in place and
raises :class:`ValueError` on any preparation failure.
"""
args.run_name = args.resume or generate_run_name(args.targets_info)
if args.resume:
return
for target_info in args.targets_info:
if target_info["type"] == "repository":
repo_url = target_info["details"]["target_repo"]
dest_name = target_info["details"].get("workspace_subdir")
cloned_path = clone_repository(repo_url, args.run_name, dest_name)
target_info["details"]["cloned_repo_path"] = cloned_path
args.local_sources = collect_local_sources(args.targets_info)
diff_scope = resolve_diff_scope_context(
local_sources=args.local_sources,
scope_mode=args.scope_mode,
diff_base=args.diff_base,
non_interactive=args.non_interactive,
)
args.diff_scope = diff_scope.metadata
if diff_scope.instruction_block:
if args.instruction:
args.instruction = f"{diff_scope.instruction_block}\n\n{args.instruction}"
else:
args.instruction = diff_scope.instruction_block
attach_workspace_mount(args)
_persist_run_record(args)
def attach_workspace_mount(args: argparse.Namespace) -> None:
"""Expose ``args.workspace_mount`` to the sandbox without making it a target.
A workspace mount is a directory the agent works in, not something to test:
it stays out of ``targets_info``, so it carries no authorized scope, and it
is attached after diff-scope resolution so it contributes no diff context.
The instruction is the only source of truth for what to do with it.
"""
mount = getattr(args, "workspace_mount", None)
if not mount:
return
args.workspace_subdir = derive_local_base_name(mount)
local_sources = list(getattr(args, "local_sources", None) or [])
local_sources.append(
{
"source_path": mount,
"workspace_subdir": args.workspace_subdir,
"protect_metadata": True,
}
)
args.local_sources = local_sources
def telemetry_start(args: argparse.Namespace) -> None:
model = load_settings().llm.model
kwargs = {
"model": model,
"auth_mode": codex.auth_mode(model),
"scan_mode": args.scan_mode,
"is_whitebox": is_whitebox_scan(args.targets_info),
"interactive": not args.non_interactive,
"has_instructions": bool(args.instruction),
}
posthog.start(**kwargs)
scarf.start(**kwargs)
def _persist_run_record(args: argparse.Namespace) -> None:
from strix.report.writer import write_run_record
run_dir = run_dir_for(args.run_name)
run_dir.mkdir(parents=True, exist_ok=True)
run_record = {
"run_id": args.run_name,
"run_name": args.run_name,
"status": "running",
"start_time": datetime.now(UTC).isoformat(),
"end_time": None,
"auth_mode": codex.auth_mode(load_settings().llm.model),
"targets_info": args.targets_info,
"scan_mode": args.scan_mode,
"instruction": args.instruction,
# Kept apart from instruction, which carries the diff-scope preamble: the
# transcript replays this as the user's opening message.
"user_instruction": getattr(args, "user_instruction", None),
"non_interactive": args.non_interactive,
"local_sources": getattr(args, "local_sources", []),
# Persisted so --resume can remount the workspace: it is not a target,
# so it cannot be rebuilt from targets_info.
"workspace_mount": getattr(args, "workspace_mount", None),
"diff_scope": getattr(args, "diff_scope", {"active": False}),
"scope_mode": args.scope_mode,
"diff_base": args.diff_base,
}
write_run_record(run_dir, run_record)
+3 -3
View File
@@ -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
+7
View File
@@ -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"]
+497
View File
@@ -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")
+136
View File
@@ -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
+61
View File
@@ -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")
+182
View File
@@ -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,
}
+40
View File
@@ -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
+531
View File
@@ -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()
+35
View File
@@ -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)
}
}
+32
View File
@@ -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
)
+61
View File
@@ -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=
+253
View File
@@ -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.
+250
View File
@@ -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")
}
}
+405
View File
@@ -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")
}
}
+523
View File
@@ -0,0 +1,523 @@
package app
import (
"fmt"
"net"
"regexp"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/usestrix/strix/tui/internal/render"
)
func (m Model) submit(value string) (tea.Model, tea.Cmd) {
if m.snapshot.SetupMode {
return m.submitSetupPrompt(value)
}
if len(m.snapshot.Agents) == 0 {
m.errorText = "No agent is available"
return m, nil
}
if m.selectedAgent >= len(m.snapshot.Agents) {
m.selectedAgent = 0
}
return m, send(m.client, "agent.send_message", map[string]any{"agent_id": m.snapshot.Agents[m.selectedAgent].ID, "message": value})
}
// submitSetupPrompt handles free text the way a coding agent's prompt does:
// anything that looks like a target is added, the rest becomes the scan
// instruction, and the prompt alone is enough to launch. With no target, the
// backend scans the current working directory.
func (m *Model) submitSetupPrompt(value string) (tea.Model, tea.Cmd) {
var commands []tea.Cmd
fields := strings.Fields(value)
targets := 0
for _, field := range fields {
token := strings.Trim(field, ",;")
if !looksLikeTarget(token) || m.hasTarget(token) {
continue
}
targets++
commands = append(commands, send(m.client, "setup.add_target", map[string]any{"target": token}))
}
if len(fields) > targets {
commands = append(commands, send(m.client, "setup.set_instruction", map[string]any{"instruction": value}))
}
// With a target, verify the model connection before the scan commits to it.
// A bare prompt launches optimistically, like a coding agent, and mounts the
// working directory - the backend asks about that from the live view, so the
// prompt is held here in case it is declined.
verify := targets > 0 || len(m.snapshot.Targets) > 0
payload := map[string]any{"verify": verify}
if verify {
m.setupMsg("Verifying model connection...", render.Col(amber))
} else {
m.pendingPrompt = value
payload["mount_working_dir"] = true
}
commands = append(commands, send(m.client, "setup.start", payload))
// Ordered, not batched: setup.start leaves setup mode, so it must be the
// last command to reach the backend. Batched sends race, and once the
// preflight is skipped setup.start wins, making the target and instruction
// commands land after the guard closes and fail with a red error.
return *m, tea.Sequence(commands...)
}
// answerMountConfirmation replies to the working-directory mount the backend is
// waiting on. Declining returns to the start screen, so the prompt goes back in
// the composer to be edited or given a target instead.
func (m *Model) answerMountConfirmation(approved bool) tea.Cmd {
if !approved && m.pendingPrompt != "" {
m.input.SetValue(m.pendingPrompt)
m.resizeViewport()
}
m.pendingPrompt = ""
return send(m.client, "setup.confirm_mount", map[string]any{"approved": approved})
}
func (m Model) hasTarget(candidate string) bool {
for _, target := range m.snapshot.Targets {
if target == candidate {
return true
}
}
return false
}
// looksLikeTarget reports whether a whitespace-delimited token names something
// scannable: a URL, repo, filesystem path, domain, or IP address.
func looksLikeTarget(token string) bool {
if token == "" {
return false
}
if strings.Contains(token, "://") || strings.HasSuffix(token, ".git") {
return true
}
if strings.HasPrefix(token, "/") || strings.HasPrefix(token, "./") || strings.HasPrefix(token, "~/") || strings.HasPrefix(token, "../") {
return true
}
if ip := net.ParseIP(token); ip != nil {
return true
}
host := token
if at := strings.LastIndex(host, "@"); at >= 0 {
host = host[at+1:]
}
host = strings.SplitN(host, "/", 2)[0]
host = strings.SplitN(host, ":", 2)[0]
if !domainPattern.MatchString(host) {
return false
}
tld := host[strings.LastIndex(host, ".")+1:]
return len(tld) >= 2 && !isNumeric(tld)
}
var domainPattern = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9]{2,}$`)
func isNumeric(value string) bool {
for _, char := range value {
if char < '0' || char > '9' {
return false
}
}
return true
}
// statusVisible mirrors #agent_status_display: shown only when an agent is
// selected during a scan; hidden (display:none) in setup mode.
func (m Model) statusVisible() bool {
return !m.snapshot.SetupMode && len(m.snapshot.Agents) > 0
}
func (m Model) layout() (showSidebar bool, sidebarWidth, chatWidth, chatHeight int) {
showSidebar = m.width >= 120
if showSidebar {
sidebarWidth = max(24, m.width/5)
chatWidth = m.width - sidebarWidth - 1
} else {
chatWidth = m.width
}
statusH := 0
if m.statusVisible() {
statusH = 1
}
chatHeight = max(4, m.height-statusH-(m.input.Height()+2))
return
}
// resizeViewport refits the composer and the scrollback to the terminal. The
// composer is sized width first: how far its content wraps, and so how tall it
// needs to be, depends on the width it is given.
func (m *Model) resizeViewport() {
if m.snapshot.SetupMode {
contentWidth := setupColumnWidth(m.width)
// The composer's border and padding each take a column per side.
m.input.SetWidth(max(3, contentWidth-4))
// A clipped placeholder reads as an unfinished sentence, so a narrow
// composer gets the short prompt instead.
m.input.Placeholder = setupPlaceholder
if contentWidth-6 < lipgloss.Width(setupPlaceholder) {
m.input.Placeholder = setupPlaceholderShort
}
m.syncInputHeight()
m.viewport.Width = max(10, contentWidth)
m.viewport.Height = max(1, setupLogRows(m.setupLog))
m.refreshViewport()
return
}
_, _, chatWidth, _ := m.layout()
// The accent bar and its padding each take a column.
m.input.SetWidth(max(3, chatWidth-3))
m.syncInputHeight()
_, _, _, chatHeight := m.layout()
// Reserve two columns inside the border for the scrollbar gap and track.
m.viewport.Width = max(10, chatWidth-4)
m.viewport.Height = max(3, chatHeight-2)
m.refreshViewport()
}
func (m *Model) refreshViewport() {
wasBottom := m.viewport.AtBottom()
content := m.setupContent()
if !m.snapshot.SetupMode {
content = m.chatContent()
}
m.viewportContent = content
m.viewport.SetContent(content)
if m.followOutput && wasBottom {
m.viewport.GotoBottom()
}
}
func (m Model) setupContent() string {
var b strings.Builder
for _, line := range m.setupLog {
b.WriteString(line + "\n")
}
return strings.TrimSuffix(b.String(), "\n")
}
// setupLogAppend records a chronological line in the setup scrollback. A line
// that is already there moves to the end instead of being repeated: retrying a
// launch that cannot succeed yet - no model configured, no target - would
// otherwise push the same pair of lines until they were all the log held.
func (m *Model) setupLogAppend(line string) {
for i, existing := range m.setupLog {
if existing == line {
m.setupLog = append(m.setupLog[:i], m.setupLog[i+1:]...)
break
}
}
m.setupLog = append(m.setupLog, line)
}
// setupMsg appends a styled feedback line (success green, error red, notice dim).
func (m *Model) setupMsg(text string, style lipgloss.Style) {
m.setupLogAppend(style.Render(text))
}
// setupLogRows is how many feedback lines the launch column shows before the
// fit starts trimming them. It is a launch pad, not a scrollback.
func setupLogRows(log []string) int { return min(len(log), 6) }
// Logo treatments, largest last. The launch column steps down through them as
// the terminal runs out of room.
const (
logoNone = iota
logoCompact
logoFull
)
// setupColumnWidth is the width of the centered launch column. It widens to
// the banner rather than lose it, as long as the terminal can still spare a
// margin either side.
func setupColumnWidth(terminal int) int {
width := min(72, max(24, terminal-8))
if terminal >= wordmarkWidth()+2 {
width = max(width, wordmarkWidth())
}
return width
}
// setupFit records how much of the launch column survives at the current
// terminal size: the wordmark treatment, whether the tagline is shown, and how
// many feedback-log rows fit.
type setupFit struct {
width int
logo int
tagline bool
logRows int
}
// setupFit picks the richest layout that still fits the terminal. Sections are
// surrendered in the order of shrink below - never the composer, which is the
// only thing on this screen the user has to reach.
func (m Model) setupFit() setupFit {
fit := setupFit{
width: setupColumnWidth(m.width),
logo: logoFull,
tagline: true,
logRows: setupLogRows(m.setupLog),
}
if m.width < wordmarkWidth()+2 {
fit.logo = logoCompact
}
if m.height < 18 {
fit.logo, fit.tagline = min(fit.logo, logoCompact), false
}
shrink := []func(*setupFit) bool{
func(f *setupFit) bool { return trimTo(&f.logRows, 3) },
func(f *setupFit) bool { return clearFlag(&f.tagline) },
func(f *setupFit) bool { return trimTo(&f.logRows, 0) },
func(f *setupFit) bool { return trimTo(&f.logo, logoCompact) },
func(f *setupFit) bool { return trimTo(&f.logo, logoNone) },
}
for step := 0; step < len(shrink) && lipgloss.Height(m.setupBody(fit)) > m.height; {
if !shrink[step](&fit) {
step++
}
}
return fit
}
func trimTo(value *int, floor int) bool {
if *value <= floor {
return false
}
*value--
return true
}
func clearFlag(flag *bool) bool {
if !*flag {
return false
}
*flag = false
return true
}
func (m Model) setupView() string {
fit := m.setupFit()
rows := strings.Split(m.setupBody(fit), "\n")
if len(rows) > m.height {
rows = rows[:max(0, m.height)]
}
// Anchor the column on its resting height rather than its current one, so a
// growing composer and new feedback both push downward.
// Centering on the live height walks the whole page up under the cursor,
// one row at a time, as the prompt wraps.
top := (m.height - m.setupRestingHeight(fit, len(rows))) / 2
top = min(max(top, 0), max(0, m.height-len(rows)))
left := max(0, (m.width-fit.width)/2)
frame := make([]string, m.height)
for row := range frame {
line := ""
if index := row - top; index >= 0 && index < len(rows) {
line = strings.Repeat(" ", left) + rows[index]
}
frame[row] = padToWidth(line, m.width)
}
return strings.Join(frame, "\n")
}
// setupRestingHeight is the column's height with the composer at its opening
// size and the transient sections closed: the layout the screen sits at when
// idle. Anchoring on this keeps the column still as the composer grows.
func (m Model) setupRestingHeight(fit setupFit, height int) int {
floor, _ := m.composerBounds()
height -= max(0, m.input.Height()-floor)
if fit.logRows > 0 && len(m.setupLog) > 0 {
height -= fit.logRows + 1
}
return height
}
// setupBody stacks the launch column: wordmark, composer with its scan summary,
// the target list, feedback and the key hints. Sections
// are separated by a blank line; the composer and its summary read as one unit.
func (m Model) setupBody(fit setupFit) string {
parts := make([]string, 0, 6)
if header := m.setupHeaderView(fit); header != "" {
parts = append(parts, header)
}
parts = append(parts, m.setupComposer(fit.width))
if log := m.setupLogView(fit); log != "" {
parts = append(parts, log)
}
parts = append(parts, m.setupHintsView(fit.width))
// Every row is padded to the column width: lipgloss.Place centers each line
// on its own, which would otherwise stagger the short rows.
rows := strings.Split(strings.Join(parts, "\n\n"), "\n")
for index, row := range rows {
rows[index] = padToWidth(row, fit.width)
}
return strings.Join(rows, "\n")
}
// setupHeaderView centers the wordmark over the tagline.
func (m Model) setupHeaderView(fit setupFit) string {
center := lipgloss.NewStyle().Width(fit.width).Align(lipgloss.Center)
var rows []string
switch fit.logo {
case logoFull:
// The banner is tall enough to want air under it.
rows = append(rows, center.Render(wordmark()))
if fit.tagline {
rows = append(rows, "")
}
case logoCompact:
rows = append(rows, center.Render(lipgloss.NewStyle().Bold(true).Foreground(brightGreen).Render("STRIX")))
}
if fit.tagline {
rows = append(rows, center.Render(render.Dim().Render("Open-source AI hackers for your apps")))
}
return strings.Join(rows, "\n")
}
// banner is the Strix wordmark: block letters with a bevelled edge.
const banner = `
`
// wordmark renders the banner in solid brand green. Every row is padded out to
// the full block so centering cannot ripple the letterforms out of alignment.
var wordmarkOnce = sync.OnceValue(func() string {
green := lipgloss.NewStyle().Foreground(green)
lines := strings.Split(banner, "\n")
rows := make([]string, len(lines))
for index, line := range lines {
rows[index] = green.Render(line + strings.Repeat(" ", wordmarkWidth()-lipgloss.Width(line)))
}
return strings.Join(rows, "\n")
})
func wordmark() string { return wordmarkOnce() }
// wordmarkWidth is the cell width of the widest banner row.
var wordmarkWidth = sync.OnceValue(func() int {
block := 0
for _, line := range strings.Split(banner, "\n") {
block = max(block, lipgloss.Width(line))
}
return block
})
// setupComposer draws the prompt as a rounded panel that lights up green while
// it holds focus. The scan meta and targets live inside the panel, flush under
// the input, so everything shares one left edge - the way opencode aligns its
// home prompt.
func (m Model) setupComposer(width int) string {
border := dark
if m.focus == focusInput {
border = green
}
// Width covers the padding but not the border, so a box of the given total
// width sets width-2 here and hands the interior the width-4 that is left.
inner := max(1, width-4)
body := m.highlightInputSelection(m.input.View())
body += "\n\n" + m.setupSummaryView(inner)
if targets := m.setupTargetsView(inner); targets != "" {
body += "\n" + targets
}
return lipgloss.NewStyle().Width(max(1, width-2)).Padding(0, 1).
Border(lipgloss.RoundedBorder()).BorderForeground(border).
Render(body)
}
// setupSummaryView is the quiet meta line inside the panel: what the scan will
// run as, or what is still missing before it can run.
func (m Model) setupSummaryView(width int) string {
chips := []string{}
if model := strings.TrimSpace(m.snapshot.Model); model != "" {
name, provider := model, ""
if slash := strings.LastIndex(model, "/"); slash >= 0 {
provider, name = model[:slash], model[slash+1:]
}
chip := render.Col(green).Render("● ") + render.Col(white).Render(name)
if provider != "" {
chips = append(chips, chip, render.Dim().Render(provider))
} else {
chips = append(chips, chip)
}
} else {
chips = append(chips, render.Col(amber).Render("○ no model")+
render.Dim().Render(" · set STRIX_LLM or configure one in your config"))
}
if m.snapshot.MaxBudgetUSD != nil {
chips = append(chips, render.Dim().Render(fmt.Sprintf("$%.2f budget", *m.snapshot.MaxBudgetUSD)))
}
return truncate(strings.Join(chips, render.Dim().Render(" · ")), max(1, width))
}
// setupTargetsView lists what the scan is pointed at, once anything is queued.
func (m Model) setupTargetsView(width int) string {
if len(m.snapshot.Targets) == 0 {
return ""
}
const visible = 4
total := max(m.snapshot.TargetCount, len(m.snapshot.Targets))
rows := []string{render.Bold(green).Render("Targets") + render.Dim().Render(fmt.Sprintf(" %d", total))}
for _, target := range m.snapshot.Targets[:min(visible, len(m.snapshot.Targets))] {
rows = append(rows, render.Col(dim).Render("▸ ")+render.Col(white).Render(truncate(target, max(1, width-2))))
}
if hidden := total - visible; hidden > 0 {
rows = append(rows, render.Dim().Render(fmt.Sprintf("+%d more", hidden)))
}
return strings.Join(rows, "\n")
}
// setupLogView shows the tail of the feedback log. The launch screen is a
// launch pad, not a scrollback, so only the most recent lines are kept.
func (m Model) setupLogView(fit setupFit) string {
if fit.logRows <= 0 || len(m.setupLog) == 0 {
return ""
}
tail := m.setupLog[max(0, len(m.setupLog)-fit.logRows):]
rows := make([]string, 0, len(tail))
for _, line := range tail {
// Align with the panel interior [2, width-2].
rows = append(rows, " "+truncate(line, max(1, fit.width-4)))
}
return strings.Join(rows, "\n")
}
// setupHintsView is the closing key hint row, aligned to the panel's inner
// edges: keys flush under the input, the version at the far right.
func (m Model) setupHintsView(width int) string {
// The panel's interior spans [2, width-2]; match it so the row reads as a
// footer under the input rather than a stray line.
const pad = " "
inner := max(1, width-4)
key := lipgloss.NewStyle().Foreground(white).Render
label := render.Dim().Render
hint := func(k, text string) string { return key(k) + label(" "+text) }
left := hint("enter", "launch scan") + label(" ") + hint("shift+enter", "newline") +
label(" ") + hint("ctrl+c", "quit")
if lipgloss.Width(left) > inner {
left = hint("enter", "launch scan")
}
right := label("v" + appVersion)
gap := inner - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 2 {
return pad + left
}
return pad + left + strings.Repeat(" ", gap) + right
}
// syncMountPrompt raises or clears the working-directory prompt to match the
// backend, which asks for it from the live view once a target-less scan is
// waiting on the answer. Following the snapshot rather than the keystroke keeps
// the prompt right across redraws and reconnects.
func (m *Model) syncMountPrompt() {
switch {
case m.snapshot.PendingMount != "" && m.modal != modalConfirmMount:
m.openModal(modalConfirmMount)
case m.snapshot.PendingMount == "" && m.modal == modalConfirmMount:
m.closeModal()
}
}
@@ -0,0 +1,95 @@
package app
import (
"encoding/json"
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"github.com/usestrix/strix/tui/internal/protocol"
"github.com/usestrix/strix/tui/internal/render"
)
// Retrying a launch that cannot succeed yet must not fill the log with copies of
// the same two lines.
func TestSetupLogCollapsesRepeatedAttempts(t *testing.T) {
m := New(nil)
m.snapshot.SetupMode = true
for range 4 {
m.setupMsg("Verifying model connection...", render.Col(amber))
m.setupMsg("No model configured. Set STRIX_LLM first.", render.Col(red))
}
if got := len(m.setupLog); got != 2 {
t.Fatalf("setup log holds %d lines after 4 identical attempts, want 2: %#v", got, m.setupLog)
}
// The newest line stays last so the log still reads chronologically.
if !strings.Contains(m.setupLog[1], "No model configured") {
t.Fatalf("most recent line is not last: %#v", m.setupLog)
}
m.setupMsg("\u2713 Added target: https://example.com", render.Col(green))
if got := len(m.setupLog); got != 3 {
t.Fatalf("a distinct line did not append: %#v", m.setupLog)
}
}
// The same collapse must hold for the path a real misconfiguration takes: a
// failing setup.start arriving as a command_result.
func TestRepeatedSetupStartFailureLogsOnce(t *testing.T) {
model, _ := newCommandTestModel(t)
model.snapshot.SetupMode = true
model.client.pending = map[string]string{}
model.client.pendingByKey = map[string]string{}
model.client.requestKeyByID = map[string]string{}
for i := range 3 {
requestID := "req-" + string(rune('a'+i))
model.client.pending[requestID] = "setup.start"
model.client.pendingByKey["setup.start"] = requestID
model.client.requestKeyByID[requestID] = "setup.start"
payload, err := json.Marshal(protocol.CommandResult{
OK: false,
Command: "setup.start",
Error: &protocol.CommandError{
Code: "invalid_state",
Message: "No model configured. Set STRIX_LLM first.",
},
})
if err != nil {
t.Fatal(err)
}
model.handleEnvelope(protocol.Envelope{
Version: protocol.Version, Type: "command_result", RequestID: requestID, Payload: payload,
})
}
if got := len(model.setupLog); got != 1 {
t.Fatalf("three identical launch failures logged %d lines, want 1: %#v", got, model.setupLog)
}
}
// Every Tab-reachable panel shows focus with the same green border.
func TestFocusedPanelsCarryTheGreenBorder(t *testing.T) {
// The profile is global; restore it so later tests still render unstyled.
previous := lipgloss.ColorProfile()
t.Cleanup(func() { lipgloss.SetColorProfile(previous) })
lipgloss.SetColorProfile(termenv.TrueColor)
borderColorsOf := func(focus focusMode) string {
m := New(nil)
m.width, m.height = 130, 30
m.showSplash = false
m.snapshot.ScanState = "running"
m.snapshot.Agents = []protocol.Agent{{ID: "a0", Name: "Strix", Status: "running"}}
m.snapshot.Vulnerabilities = []map[string]any{{"title": "XSS", "severity": "high"}}
m.focus = focus
m.resizeViewport()
return m.sidebarView(26, m.height)
}
idle := borderColorsOf(focusInput)
if strings.Contains(idle, "34;197;94") {
t.Fatal("an unfocused sidebar panel drew a green border")
}
for _, focus := range []focusMode{focusAgents, focusVulnerabilities} {
if !strings.Contains(borderColorsOf(focus), "34;197;94") {
t.Fatalf("focus %v did not draw a green border", focus)
}
}
}
@@ -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)
}
}
+600
View File
@@ -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()
}
}
+782
View File
@@ -0,0 +1,782 @@
package app
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/usestrix/strix/tui/internal/protocol"
"github.com/usestrix/strix/tui/internal/render"
)
// eventSpan records which content lines of the chat trace belong to an
// expandable tool event, so clicks can toggle its collapsed state.
type eventSpan struct {
start, end int
eventID string
}
// renderedBlock is a chat block kept across frames: rendering (syntax
// highlighting, image placements, wrapping) is expensive and only changes
// when the event, the chat width, or its expanded state changes.
type renderedBlock struct {
version int
width int
expanded bool
wrapped string
expandable bool
height int
}
func (m *Model) renderEvent(event protocol.Event, width int) renderedBlock {
expanded := m.expandedEvents[event.ID]
if cached, ok := m.blockCache[event.ID]; ok &&
cached.version == event.Version && cached.width == width && cached.expanded == expanded {
return cached
}
var block string
expandable := false
switch event.Type {
case "chat":
block = render.Chat(event.Data)
case "tool":
name := render.StringValue(event.Data["tool_name"])
block, expandable = render.CollapseTool(render.Tool(event.Data), name, expanded)
}
entry := renderedBlock{version: event.Version, width: width, expanded: expanded, expandable: expandable}
if block != "" {
entry.wrapped = wrapBlock(block, width)
entry.height = strings.Count(entry.wrapped, "\n") + 1
}
if m.blockCache == nil {
m.blockCache = map[string]renderedBlock{}
}
m.blockCache[event.ID] = entry
return entry
}
func (m *Model) chatContent() string {
if len(m.snapshot.Agents) == 0 {
switch m.snapshot.ScanState {
case "failed":
message := "Scan failed"
if m.snapshot.Error != nil && strings.TrimSpace(*m.snapshot.Error) != "" {
detail := strings.ReplaceAll(strings.TrimSpace(*m.snapshot.Error), "\n", " ")
message += "\n\n" + ansi.Truncate(detail, max(1, m.viewport.Width-4), "...")
}
return centeredPlaceholder(message, m.viewport.Width, m.viewport.Height)
case "stopped":
return centeredPlaceholder("Scan stopped", m.viewport.Width, m.viewport.Height)
case "completed":
return centeredPlaceholder("Scan completed", m.viewport.Width, m.viewport.Height)
case "preparing":
return centeredPlaceholder("Preparing scan...", m.viewport.Width, m.viewport.Height)
default:
return centeredPlaceholder("Loading...", m.viewport.Width, m.viewport.Height)
}
}
if m.selectedAgent >= len(m.snapshot.Agents) {
return ""
}
agentID := m.snapshot.Agents[m.selectedAgent].ID
events := append([]protocol.Event(nil), m.snapshot.Events...)
// Match _gather_agent_events: sort by (timestamp, id).
sort.SliceStable(events, func(i, j int) bool {
if events[i].Timestamp != events[j].Timestamp {
return events[i].Timestamp < events[j].Timestamp
}
return events[i].ID < events[j].ID
})
// .chat-content has padding: 0 1 — one column of horizontal padding, so wrap
// to width-2 and indent every line by one cell.
contentWidth := max(1, m.viewport.Width-2)
render.SetImageWidth(contentWidth - 2)
var blocks []string
var spans []eventSpan
line := 0
for _, event := range events {
if event.AgentID != agentID {
continue
}
entry := m.renderEvent(event, contentWidth)
if entry.wrapped == "" {
continue
}
if len(blocks) > 0 {
line++ // blank separator line between blocks
}
if entry.expandable {
spans = append(spans, eventSpan{start: line, end: line + entry.height - 1, eventID: event.ID})
}
line += entry.height
blocks = append(blocks, entry.wrapped)
}
m.eventSpans = spans
if len(blocks) == 0 {
return centeredPlaceholder("Starting agent...", m.viewport.Width, m.viewport.Height)
}
return indentLines(strings.Join(blocks, "\n\n"), " ")
}
// indentLines prefixes every line with the given pad (chat-content padding-left).
func indentLines(s, pad string) string {
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = pad + line
}
return strings.Join(lines, "\n")
}
func centeredPlaceholder(text string, width, height int) string {
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, lipgloss.NewStyle().Foreground(dim).Italic(true).Render(text))
}
// truncate clips to a display-cell width, honoring wide runes and ANSI styling.
func truncate(value string, limit int) string {
if limit <= 0 {
return ""
}
if ansi.StringWidth(value) <= limit {
return value
}
return ansi.Truncate(value, limit, "…")
}
// wrapBlock hard-wraps each line of a rendered block to the given cell width so
// content never spills past the chat border, matching Textual's word wrapping.
func wrapBlock(value string, width int) string {
if width <= 0 {
return value
}
var out []string
for _, line := range strings.Split(value, "\n") {
if ansi.StringWidth(line) <= width {
out = append(out, line)
continue
}
out = append(out, strings.Split(ansi.Wrap(line, width, " -"), "\n")...)
}
return strings.Join(out, "\n")
}
func verticalScrollbar(height, total, visible, offset int, thumb lipgloss.Color) string {
if height <= 0 || total <= visible {
return ""
}
visible = min(max(1, visible), max(1, total))
total = max(visible, total)
thumbHeight := height
thumbStart := 0
if total > visible {
thumbHeight = max(1, height*visible/total)
maxOffset := total - visible
thumbStart = (height - thumbHeight) * min(max(0, offset), maxOffset) / maxOffset
}
thumbStyle := lipgloss.NewStyle().Foreground(thumb)
bar := make([]string, height)
for row := range bar {
bar[row] = " "
if row >= thumbStart && row < thumbStart+thumbHeight {
bar[row] = thumbStyle.Render("█")
}
}
return strings.Join(bar, "\n")
}
// withVerticalScrollbar reserves a single column for the bar, and only while the
// panel actually overflows.
func withVerticalScrollbar(
content string,
width, height, total, visible, offset int,
thumb lipgloss.Color,
) string {
if total <= visible {
return fixedPanelBody(content, width, height)
}
body := fixedPanelBody(content, max(1, width-1), height)
bar := verticalScrollbar(height, total, visible, offset, thumb)
return lipgloss.JoinHorizontal(lipgloss.Top, body, bar)
}
func visibleContent(content string, offset, height int) string {
if height <= 0 || content == "" {
return ""
}
lines := strings.Split(content, "\n")
start := min(max(0, offset), len(lines))
end := min(len(lines), start+height)
return strings.Join(lines[start:end], "\n")
}
func fixedPanelBody(content string, width, height int) string {
lines := strings.Split(content, "\n")
body := make([]string, max(0, height))
for row := range body {
line := ""
if row < len(lines) {
line = ansi.Truncate(lines[row], max(1, width), "")
}
padding := strings.Repeat(" ", max(0, width-ansi.StringWidth(line)))
// End every source style before padding; otherwise inline-code and tool
// backgrounds can paint the empty space through to the panel border.
body[row] = line + "\x1b[0m" + blackBG + padding
}
return strings.Join(body, "\n")
}
func (m Model) View() string {
view := fillBackground(m.viewInner())
// Kitty graphics transmissions ride out of band: they carry no visible
// cells, so writing them directly keeps the Bubble Tea frame diff clean.
for _, seq := range render.DrainImageTransmissions() {
_, _ = os.Stdout.WriteString(seq)
}
return view
}
func (m Model) viewInner() string {
if m.showSplash {
return m.splashView()
}
if !m.ready {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, lipgloss.NewStyle().Foreground(dim).Render("Connecting to Strix…"), lipgloss.WithWhitespaceBackground(black))
}
main := m.mainView()
if m.snapshot.SetupMode {
main = m.setupView()
}
if m.modal == modalConfirmMount {
// A corner prompt, not a dialog: it sits out of the way in the live view
// while the scan waits on the answer.
main = m.cornerOverlay(main, m.modalView())
} else if m.modal != modalNone {
// Only the vulnerability detail dims its backdrop (#000000 80%); Help,
// Quit and Stop are transparent.
main = m.overlay(main, m.modalView(), m.modal == modalVulnerability)
}
return m.toastOverlay(main)
}
// cornerOverlay splices a panel in directly above the composer, right-aligned
// with it, leaving the rest of the view visible behind it.
func (m Model) cornerOverlay(view, panel string) string {
if panel == "" {
return view
}
fg := strings.Split(panel, "\n")
bg := strings.Split(view, "\n")
panelWidth := lipgloss.Width(panel)
// Right edge of the chat column, so it lines up with the composer rather
// than covering the sidebar.
_, _, chatWidth, _ := m.layout()
left := max(0, min(chatWidth, m.width)-panelWidth)
// Bottom row sits just above the composer, clearing the status line so the
// scan state and quit hint stay readable.
statusH := 0
if m.statusVisible() {
statusH = 1
}
top := max(0, m.inputTop()-statusH-len(fg))
for row := top; row < min(len(bg), top+len(fg)); row++ {
fgLine := ansi.Truncate(fg[row-top], max(0, m.width-left), "")
rightStart := left + lipgloss.Width(fgLine)
leftPart := padToWidth(ansi.Truncate(bg[row], left, ""), left)
rightPart := ""
if lipgloss.Width(bg[row]) > rightStart {
rightPart = ansi.TruncateLeft(bg[row], rightStart, "")
}
bg[row] = leftPart + fgLine + rightPart
}
return strings.Join(bg, "\n")
}
// toastOverlay splices a transient notification into the bottom-right corner,
// where Textual's notify() toasts appeared.
func (m Model) toastOverlay(view string) string {
if m.toast == "" {
return view
}
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(green).
Background(black).
Foreground(textColor).
Padding(0, 1).
Render(m.toast)
fg := strings.Split(box, "\n")
bg := strings.Split(view, "\n")
boxWidth := lipgloss.Width(box)
left := max(0, m.width-boxWidth-2)
top := max(0, m.height-len(fg)-1)
for row := top; row < min(len(bg), top+len(fg)); row++ {
fgLine := fg[row-top]
rightStart := left + boxWidth
leftPart := padToWidth(ansi.Truncate(bg[row], left, ""), left)
rightPart := ""
if lipgloss.Width(bg[row]) > rightStart {
rightPart = ansi.TruncateLeft(bg[row], rightStart, "")
}
bg[row] = leftPart + fgLine + rightPart
}
return strings.Join(bg, "\n")
}
// blackBG is the SGR that selects a solid black background.
const blackBG = "\x1b[48;2;0;0;0m"
// fillBackground paints the whole frame black like Textual's Screen background.
// Bubble Tea has no screen compositor, so any cell the view does not explicitly
// color shows the terminal's default background. lipgloss emits a full reset
// (\x1b[0m) at the end of every styled span, which also clears the background, so
// we reassert black after each reset (and at the start). Spans that set their own
// background — inline code, selected rows, buttons — keep it, because their color
// is emitted before the reset.
func fillBackground(view string) string {
if view == "" {
return view
}
return blackBG + strings.ReplaceAll(view, "\x1b[0m", "\x1b[0m"+blackBG)
}
func (m Model) splashView() string {
shine := "Starting Strix Agent"
chars := []rune(shine)
pos := m.splashFrame % (len(chars) + 8)
var start strings.Builder
for i, char := range chars {
distance := i - pos
if distance < 0 {
distance = -distance
}
// Tiers match SplashScreen._build_start_line_text:
// bright_white / white / #a3a3a3 / #525252.
color := lipgloss.Color("#525252")
bold := false
switch {
case distance <= 1:
color, bold = brightWhite, true
case distance <= 3:
color, bold = white, true
case distance <= 5:
color = lipgloss.Color("#a3a3a3")
}
start.WriteString(lipgloss.NewStyle().Foreground(color).Bold(bold).Render(string(char)))
}
welcome := lipgloss.NewStyle().Bold(true).Foreground(white).Render("Welcome to ") +
lipgloss.NewStyle().Bold(true).Foreground(green).Render("Strix") +
lipgloss.NewStyle().Bold(true).Foreground(white).Render("!")
version := lipgloss.NewStyle().Foreground(white).Faint(true).Render("v" + appVersion)
tagline := lipgloss.NewStyle().Foreground(white).Faint(true).Render("Open-source AI hackers for your apps")
url := lipgloss.NewStyle().Bold(true).Foreground(green).Render("strix.ai")
// The wordmark is shared with the launch screen so the two read as one moment.
content := wordmark() + "\n\n" +
welcome + "\n" + version + "\n" + tagline + "\n\n" +
start.String() + "\n\n" + url
if warn := m.snapshot.ModelWarning; warn != "" {
content += "\n\n" + splashModelWarning(warn)
}
panel := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(green).Padding(1, 6).Align(lipgloss.Center).Render(content)
// #splash_screen background is solid black.
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, panel,
lipgloss.WithWhitespaceBackground(black))
}
// splashModelWarning ports SplashScreen._build_model_warning_text.
func splashModelWarning(model string) string {
yellow := lipgloss.Color("#eab308")
return lipgloss.NewStyle().Bold(true).Foreground(yellow).Render("⚠ ") +
lipgloss.NewStyle().Bold(true).Foreground(render.Cyan).Render(model) +
lipgloss.NewStyle().Foreground(yellow).Render(" is not a recommended frontier model - pentest quality could be degraded")
}
// chatPaneKey identifies everything the bordered trace depends on.
type chatPaneKey struct {
offset int
width, height int
border lipgloss.Color
selection selectionState
}
// chatPane memoizes the bordered trace: slicing, scrollbar padding and border
// styling all re-measure every visible cell, which is costly when inline image
// placeholders (a base rune plus two combining marks per cell) fill the pane,
// and the trace is unchanged across most frames.
var chatPane struct {
key chatPaneKey
content string
out string
}
func (m Model) renderChatPane(width, height int, border lipgloss.Color) string {
key := chatPaneKey{offset: m.viewport.YOffset, width: width, height: height, border: border, selection: m.selection}
if chatPane.out != "" && chatPane.key == key && chatPane.content == m.viewportContent {
return chatPane.out
}
trace := withVerticalScrollbar(
m.highlightSelection(visibleContent(m.viewportContent, m.viewport.YOffset, height), m.viewport.YOffset),
width,
height,
m.viewport.TotalLineCount(),
m.viewport.VisibleLineCount(),
m.viewport.YOffset,
thumbTrace,
)
out := lipgloss.NewStyle().Width(width).Height(height).
Border(lipgloss.RoundedBorder()).BorderForeground(border).Render(trace)
chatPane.key, chatPane.content, chatPane.out = key, m.viewportContent, out
return out
}
func (m Model) mainView() string {
showSidebar, sidebarWidth, chatWidth, chatHeight := m.layout()
// Matches tui_styles.tcss: #chat_history border is near-black when idle and
// green on focus.
chatBorder := lipgloss.Color("#0a0a0a")
if m.focus == focusChat {
chatBorder = green
}
traceHeight := chatHeight - 2
chat := m.renderChatPane(chatWidth-2, traceHeight, chatBorder)
inputBorder := dark
if m.focus == focusInput {
inputBorder = green
}
input := lipgloss.NewStyle().Width(chatWidth - 2).Height(m.input.Height()).
Border(lipgloss.RoundedBorder()).BorderForeground(inputBorder).PaddingLeft(1).
Render(m.highlightInputSelection(m.input.View()))
// Chat column: chat history, optional status row, then input — all chat-width.
leftParts := []string{chat}
if m.statusVisible() {
leftParts = append(leftParts, m.statusView(chatWidth))
}
leftParts = append(leftParts, input)
leftColumn := strings.Join(leftParts, "\n")
body := leftColumn
if showSidebar {
body = lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", m.sidebarView(sidebarWidth, m.height))
}
return lipgloss.NewStyle().Background(black).Foreground(textColor).Render(body)
}
// Every panel that Tab can reach shows focus the way the chat and the composer
// do, with a green border. The stylesheet asked for near-black on the tree
// instead, through a Tree:focus rule that lost to the #agents_tree id selector
// and so never applied - honoring it made the outline vanish on the one panel
// that had just become active.
func (m Model) sidebarView(width, height int) string {
// Stats box height fits its content (auto, max 15); vulns panel max-height 12.
statsBody := m.statsView()
statsHeight, vulnHeight, agentHeight := m.sidebarHeights()
agentBorder := dark
if m.focus == focusAgents {
agentBorder = green
}
// #agents_tree padding: 1 (all sides); interior lines = box - border - v.padding.
agentRows := max(1, agentHeight-4)
agentEntries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)
agents := withVerticalScrollbar(
m.agentsView(max(1, width-5), agentRows),
width-4,
agentRows,
len(agentEntries),
agentRows,
m.agentOffset,
thumbAgents,
)
parts := []string{
lipgloss.NewStyle().Width(width-2).Height(m.viewerHeight()-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(m.viewerView(width - 4)),
lipgloss.NewStyle().Width(width-2).Height(agentHeight-2).Border(lipgloss.RoundedBorder()).BorderForeground(agentBorder).Padding(1, 1).Render(agents),
}
if vulnHeight > 0 {
vulnBorder := dark
if m.focus == focusVulnerabilities {
vulnBorder = green
}
vulnRows := max(1, vulnHeight-2)
totalRows, offsetRows := m.vulnerabilityScrollRows()
findings := withVerticalScrollbar(
m.vulnerabilitiesView(max(1, width-5), vulnRows),
width-4,
vulnRows,
totalRows,
vulnRows,
offsetRows,
thumbFindings,
)
parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(vulnRows).Border(lipgloss.RoundedBorder()).BorderForeground(vulnBorder).Padding(0, 1).Render(findings))
}
parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(statsHeight-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(statsBody))
return strings.Join(parts, "\n")
}
func (m Model) sidebarHeights() (statsHeight, vulnHeight, agentHeight int) {
// Measure the stats panel the way its box will render it: a long model name
// wraps inside the sidebar, and counting only its newlines would size the
// box short and push the whole frame past the bottom of the terminal.
statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.viewerContentWidth()).Render(m.statsView()))
statsHeight = min(15, statsRows+2)
if len(m.snapshot.Vulnerabilities) > 0 {
rows := 0
width := m.vulnerabilityListWidth()
for i := range m.snapshot.Vulnerabilities {
rows += len(m.vulnerabilityTitleLines(i, width))
}
vulnHeight = min(12, rows+2)
}
agentHeight = max(3, m.height-m.viewerHeight()-statsHeight-vulnHeight)
return
}
func (m Model) viewerHeight() int {
return strings.Count(m.viewerView(m.viewerContentWidth()), "\n") + 3
}
func (m Model) viewerContentWidth() int {
_, sidebarWidth, _, _ := m.layout()
if sidebarWidth == 0 {
sidebarWidth = 24
}
return max(1, sidebarWidth-4)
}
func (m Model) viewerView(width int) string {
switch m.snapshot.ViewerStatus {
case "running":
status := lipgloss.NewStyle().Foreground(green).Render("● Viewer running")
if m.snapshot.ViewerURL != nil && strings.TrimSpace(*m.snapshot.ViewerURL) != "" {
url := wrapBlock(strings.TrimSpace(*m.snapshot.ViewerURL), width)
return status + "\n" + lipgloss.NewStyle().Foreground(dim).Render(url)
}
return status
case "unavailable":
return truncate(lipgloss.NewStyle().Foreground(amber).Render("Viewer UI not built"), width)
case "failed":
return truncate(lipgloss.NewStyle().Foreground(red).Render("Viewer failed to start"), width)
default:
return truncate(lipgloss.NewStyle().Foreground(textColor).Render("▶ Watch live in browser"), width)
}
}
func (m Model) statsView() string {
w := lipgloss.NewStyle().Foreground(white)
var b strings.Builder
if model := m.snapshot.Model; model != "" {
b.WriteString(w.Render(model))
}
if m.snapshot.Subscription {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(lipgloss.NewStyle().Foreground(green).Render("ChatGPT subscription"))
}
total := numberValue(m.snapshot.Usage["total_tokens"])
if total > 0 {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(w.Render(fmt.Sprintf("%s tokens", formatCount(total))))
if cost := floatValue(m.snapshot.Usage["cost"]); !m.snapshot.Subscription && cost > 0 {
b.WriteString(w.Render(fmt.Sprintf(" · $%.2f", cost)))
}
}
if caido := m.snapshot.CaidoURL; caido != "" {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(white).Render("Caido: ") + w.Render(caido))
}
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(w.Render("v" + appVersion))
return b.String()
}
func numberValue(value any) int64 {
switch v := value.(type) {
case float64:
return int64(v)
case int64:
return v
case int:
return int64(v)
case json.Number:
n, _ := v.Int64()
return n
}
return 0
}
func floatValue(value any) float64 {
switch v := value.(type) {
case float64:
return v
case int:
return float64(v)
case string:
n, _ := strconv.ParseFloat(v, 64)
return n
}
return 0
}
func formatCount(value int64) string {
if value >= 1_000_000 {
return fmt.Sprintf("%.1fM", float64(value)/1_000_000)
}
if value >= 1_000 {
return fmt.Sprintf("%.1fK", float64(value)/1_000)
}
return strconv.FormatInt(value, 10)
}
func (m Model) statusView(width int) string {
// Status text color mirrors #status_text (#a3a3a3); keymap hints use white
// keys and dim actions (keymap_styled). See _get_status_display_content.
left, right := "", ""
if len(m.snapshot.Agents) > 0 && !m.snapshot.SetupMode {
agent := m.snapshot.Agents[m.selectedAgent]
quitHint := lipgloss.NewStyle().Foreground(white).Render("ctrl-q") + lipgloss.NewStyle().Foreground(dim).Render(" ") + lipgloss.NewStyle().Foreground(dim).Render("quit")
switch agent.Status {
case "running":
if m.agentHasEvents(agent.ID) {
left = m.sweepView() + lipgloss.NewStyle().Foreground(white).Render("esc") + lipgloss.NewStyle().Foreground(dim).Render(" ") + lipgloss.NewStyle().Foreground(dim).Render("stop")
} else {
left = m.sweepView() + lipgloss.NewStyle().Foreground(white).Render("Initializing")
}
right = quitHint
case "waiting":
left = lipgloss.NewStyle().Foreground(dim).Render("Send message to resume")
if msg := agent.ErrorMessage; msg != "" {
left = lipgloss.NewStyle().Foreground(red).Render(msg) +
lipgloss.NewStyle().Foreground(dim).Render(" · Send message to resume")
}
case "budget_paused":
left = lipgloss.NewStyle().Foreground(amber).Render("Budget limit reached") +
lipgloss.NewStyle().Foreground(dim).Render(" · Send a message to continue")
right = quitHint
case "completed":
left = lipgloss.NewStyle().Foreground(mid).Render("Agent completed")
case "stopped":
left = lipgloss.NewStyle().Foreground(mid).Render("Agent stopped")
case "failed", "crashed":
msg := agent.ErrorMessage
if msg == "" {
msg = "Agent failed"
}
left = lipgloss.NewStyle().Foreground(red).Render(msg) +
lipgloss.NewStyle().Foreground(dim).Render(" · Send message to resume")
}
}
if m.errorText != "" {
left = lipgloss.NewStyle().Foreground(red).Render(m.errorText)
}
gap := max(1, width-lipgloss.Width(left)-lipgloss.Width(right))
return " " + left + strings.Repeat(" ", max(1, gap-1)) + right
}
func (m Model) sweepView() string {
palette := []lipgloss.Color{
black, lipgloss.Color("#031a09"), lipgloss.Color("#052e16"), lipgloss.Color("#0d4a2a"),
lipgloss.Color("#15803d"), green, brightGreen, lipgloss.Color("#86efac"),
}
const numSquares = 6
numColors := len(palette)
offset := numColors - 1
maxPos := (numSquares - 1) + offset
totalRange := maxPos + offset
cycleLength := totalRange * 2
frameInCycle := m.sweepFrame % cycleLength
wavePos := totalRange - abs(totalRange-frameInCycle)
sweepPos := wavePos - offset
dotColor := lipgloss.Color("#0a3d1f")
var b strings.Builder
for i := 0; i < numSquares; i++ {
dist := abs(i - sweepPos)
colorIdx := numColors - 1 - dist
if colorIdx <= 0 {
b.WriteString(lipgloss.NewStyle().Foreground(dotColor).Render("·"))
} else {
b.WriteString(lipgloss.NewStyle().Foreground(palette[colorIdx]).Render("▪"))
}
}
b.WriteString(" ")
return b.String()
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
func titleCase(s string) string {
return strings.Title(strings.ToLower(s))
}
// overlay composites a centered dialog on top of the live main view. When
// dimmed is true (vulnerability detail, background: #000000 80%) the backdrop is
// recolored to a dark grey; otherwise it is left untouched to match Textual's
// transparent modal backdrop (background: $background 0%).
func (m Model) overlay(background, foreground string, dimmed bool) string {
bg := strings.Split(background, "\n")
fg := strings.Split(foreground, "\n")
dialogHeight := len(fg)
dialogWidth := lipgloss.Width(foreground)
top := max(0, (m.height-dialogHeight)/2)
left := max(0, (m.width-dialogWidth)/2)
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#3f3f46"))
for row := 0; row < len(bg); row++ {
if row < top || row >= top+dialogHeight {
if dimmed {
bg[row] = dimStyle.Render(ansi.Strip(bg[row]))
}
continue
}
fgLine := fg[row-top]
rightStart := left + dialogWidth
var leftPart, rightPart string
if dimmed {
bgLine := ansi.Strip(bg[row])
leftPart = dimStyle.Render(truncateToWidth(bgLine, left))
if lipgloss.Width(bgLine) > rightStart {
rightPart = dimStyle.Render(ansi.TruncateLeft(bgLine, rightStart, ""))
}
} else {
// Preserve the original styling of the visible backdrop segments.
leftPart = padToWidth(ansi.Truncate(bg[row], left, ""), left)
if lipgloss.Width(bg[row]) > rightStart {
rightPart = ansi.TruncateLeft(bg[row], rightStart, "")
}
}
bg[row] = leftPart + fgLine + rightPart
}
return strings.Join(bg, "\n")
}
// padToWidth right-pads an ANSI string to an exact display width.
func padToWidth(value string, width int) string {
w := lipgloss.Width(value)
if w >= width {
return value
}
return value + strings.Repeat(" ", width-w)
}
func truncateToWidth(value string, width int) string {
if width <= 0 {
return ""
}
if lipgloss.Width(value) <= width {
return value + strings.Repeat(" ", width-lipgloss.Width(value))
}
return ansi.Truncate(value, width, "")
}
@@ -0,0 +1,176 @@
package app
// Markdown clipboard report for the vulnerability detail dialog, porting
// VulnerabilityDetailScreen._get_markdown_report plus the report-writer fence
// helpers (safe_fence, guess_language_name).
import (
"fmt"
"regexp"
"strings"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/usestrix/strix/tui/internal/render"
)
var backtickRun = regexp.MustCompile("`+")
// safeFence returns a backtick fence that content cannot break out of: one
// backtick longer than the longest run inside it, never fewer than three.
func safeFence(content string) string {
longest := 0
for _, run := range backtickRun.FindAllString(content, -1) {
longest = max(longest, len(run))
}
return strings.Repeat("`", max(3, longest+1))
}
// guessLanguageName returns a markdown fence tag for code, defaulting to
// "python" when auto-detection is inconclusive (legacy PoC scripts are Python).
func guessLanguageName(code string) string {
lexer := lexers.Analyse(code)
if lexer == nil {
return "python"
}
config := lexer.Config()
if config == nil || len(config.Aliases) == 0 || config.Name == "plaintext" {
return "python"
}
return config.Aliases[0]
}
func titleCaseWords(text string) string {
words := strings.Fields(text)
for i, word := range words {
words[i] = titleCase(word)
}
return strings.Join(words, " ")
}
// vulnerabilityMarkdownReport builds the Markdown vulnerability report copied
// to the clipboard, field-for-field with the old Textual screen.
func vulnerabilityMarkdownReport(v map[string]any) string {
var lines []string
title := render.StringValue(v["title"])
if title == "" {
title = "Untitled Vulnerability"
}
lines = append(lines, "# "+title, "")
field := func(label, value string) {
if value != "" {
lines = append(lines, fmt.Sprintf("**%s:** %s", label, value))
}
}
field("ID", render.StringValue(v["id"]))
field("Severity", strings.ToUpper(render.StringValue(v["severity"])))
field("Found", render.StringValue(v["timestamp"]))
field("Agent", render.StringValue(v["agent_name"]))
field("Target", render.StringValue(v["target"]))
if dep, ok := v["dependency_metadata"].(map[string]any); ok {
field("Package", render.StringValue(dep["package_name"]))
field("Ecosystem", render.StringValue(dep["package_ecosystem"]))
field("Installed Version", render.StringValue(dep["installed_version"]))
field("Fixed Version", render.StringValue(dep["fixed_version"]))
}
field("Endpoint", render.StringValue(v["endpoint"]))
field("Method", render.StringValue(v["method"]))
field("CVE", render.StringValue(v["cve"]))
field("CWE", render.StringValue(v["cwe"]))
field("CVSS", render.StringValue(v["cvss"]))
if fe := render.StringValue(v["fix_effort"]); fe != "" {
field("Fix Effort", titleCaseWords(fe))
}
if bd, ok := v["cvss_breakdown"].(map[string]any); ok && len(bd) > 0 {
if parts := render.CVSSVectorParts(bd); len(parts) > 0 {
field("CVSS Vector", strings.Join(parts, "/"))
}
}
description := render.StringValue(v["description"])
if description == "" {
description = "No description provided."
}
lines = append(lines, "", "## Description", "", description)
section := func(label, value string) {
if value != "" {
lines = append(lines, "", "## "+label, "", value)
}
}
section("Impact", render.StringValue(v["impact"]))
section("Technical Analysis", render.StringValue(v["technical_analysis"]))
section("Evidence", render.StringValue(v["evidence"]))
pocDescription := render.StringValue(v["poc_description"])
pocScript := render.StringValue(v["poc_script_code"])
if pocDescription != "" || pocScript != "" {
lines = append(lines, "", "## Proof of Concept", "")
if pocDescription != "" {
lines = append(lines, pocDescription, "")
}
if pocScript != "" {
pocLang, pocCode := render.ParseFencedCode(pocScript)
if pocLang == "" {
pocLang = guessLanguageName(pocCode)
}
fence := safeFence(pocCode)
lines = append(lines, fence+pocLang, pocCode, fence)
}
}
if locations, ok := v["code_locations"].([]any); ok && len(locations) > 0 {
lines = append(lines, "", "## Code Analysis", "")
for i, item := range locations {
loc, ok := item.(map[string]any)
if !ok {
continue
}
file := render.StringValue(loc["file"])
if file == "" {
file = "unknown"
}
lineRef := ""
if start := render.StringValue(loc["start_line"]); start != "" {
if end := render.StringValue(loc["end_line"]); end != "" && end != start {
lineRef = fmt.Sprintf(" (lines %s-%s)", start, end)
} else {
lineRef = fmt.Sprintf(" (line %s)", start)
}
}
lines = append(lines, fmt.Sprintf("**Location %d:** `%s`%s", i+1, file, lineRef))
if label := render.StringValue(loc["label"]); label != "" {
lines = append(lines, " "+label)
}
if snippet := render.StringValue(loc["snippet"]); snippet != "" {
fence := safeFence(snippet)
lines = append(lines, fence+"\n"+snippet+"\n"+fence)
}
before := render.StringValue(loc["fix_before"])
after := render.StringValue(loc["fix_after"])
if before != "" || after != "" {
lines = append(lines, "**Suggested Fix:**", "```diff")
if before != "" {
for _, l := range strings.Split(before, "\n") {
lines = append(lines, "- "+l)
}
}
if after != "" {
for _, l := range strings.Split(after, "\n") {
lines = append(lines, "+ "+l)
}
}
lines = append(lines, "```")
}
lines = append(lines, "")
}
}
section("Remediation", render.StringValue(v["remediation_steps"]))
section("Assumptions", render.StringValue(v["assumptions"]))
lines = append(lines, "")
return strings.Join(lines, "\n")
}
@@ -0,0 +1,405 @@
package app
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/usestrix/strix/tui/internal/render"
)
var panelSeverityColors = map[string]lipgloss.Color{
"critical": render.SevCrit, "high": render.SevHigh, "medium": render.SevMed, "low": green, "info": blue,
}
func (m Model) vulnerabilitiesView(width, height int) string {
var lines []string
start := min(max(0, m.vulnOffset), max(0, len(m.snapshot.Vulnerabilities)-1))
for i := start; i < len(m.snapshot.Vulnerabilities) && len(lines) < height; i++ {
vuln := m.snapshot.Vulnerabilities[i]
severity := strings.ToLower(render.StringValue(vuln["severity"]))
color, ok := panelSeverityColors[severity]
if !ok {
color = blue // matches SEVERITY_COLORS.get(severity, "#3b82f6")
}
marker := lipgloss.NewStyle().Foreground(color).Render("● ")
style := lipgloss.NewStyle().Foreground(textColor)
if i == m.selectedVuln {
style = style.Bold(true).Foreground(white)
}
for row, titleLine := range m.vulnerabilityTitleLines(i, width) {
if len(lines) >= height {
break
}
prefix := " "
if row == 0 {
prefix = marker
}
lines = append(lines, prefix+style.Render(titleLine))
}
}
return strings.Join(lines, "\n")
}
func (m Model) vulnerabilityListWidth() int {
_, sidebarWidth, _, _ := m.layout()
return max(1, sidebarWidth-6)
}
func (m Model) vulnerabilityTitleLines(index, width int) []string {
title := render.StringValue(m.snapshot.Vulnerabilities[index]["title"])
if title == "" {
title = "Unknown Vulnerability"
}
return strings.Split(wrapBlock(title, max(1, width-2)), "\n")
}
func (m Model) vulnerabilityScrollRows() (total, offset int) {
width := m.vulnerabilityListWidth()
for i := range m.snapshot.Vulnerabilities {
rows := len(m.vulnerabilityTitleLines(i, width))
total += rows
if i < m.vulnOffset {
offset += rows
}
}
return total, offset
}
func (m Model) vulnerabilityOffsetAtRow(targetRow int) int {
width := m.vulnerabilityListWidth()
row := 0
for i := range m.snapshot.Vulnerabilities {
row += len(m.vulnerabilityTitleLines(i, width))
if targetRow < row {
return i
}
}
return max(0, len(m.snapshot.Vulnerabilities)-1)
}
func (m Model) vulnerabilityVisibleEnd(start int) int {
height := m.vulnerabilityPageSize()
width := m.vulnerabilityListWidth()
rows := 0
end := min(max(0, start), len(m.snapshot.Vulnerabilities))
for end < len(m.snapshot.Vulnerabilities) {
itemRows := len(m.vulnerabilityTitleLines(end, width))
if rows > 0 && rows+itemRows > height {
break
}
rows += itemRows
end++
if rows >= height {
break
}
}
return end
}
func (m Model) vulnerabilityIndexAtRow(row int) int {
width := m.vulnerabilityListWidth()
currentRow := 0
for i := m.vulnOffset; i < m.vulnerabilityVisibleEnd(m.vulnOffset); i++ {
currentRow += len(m.vulnerabilityTitleLines(i, width))
if row < currentRow {
return i
}
}
return -1
}
func (m *Model) ensureVulnerabilityVisible() {
if len(m.snapshot.Vulnerabilities) == 0 {
m.vulnOffset = 0
return
}
if m.selectedVuln < m.vulnOffset {
m.vulnOffset = m.selectedVuln
}
for m.selectedVuln >= m.vulnerabilityVisibleEnd(m.vulnOffset) && m.vulnOffset < m.selectedVuln {
m.vulnOffset++
}
m.vulnOffset = min(m.vulnOffset, len(m.snapshot.Vulnerabilities)-1)
}
func (m Model) vulnerabilityPageSize() int {
_, vulnHeight, _ := m.sidebarHeights()
return max(1, vulnHeight-2)
}
func (m Model) vulnerabilityPageItems() int {
return max(1, m.vulnerabilityVisibleEnd(m.vulnOffset)-m.vulnOffset)
}
func (m *Model) moveVulnerabilitySelection(delta int) {
m.selectedVuln = max(0, min(len(m.snapshot.Vulnerabilities)-1, m.selectedVuln+delta))
}
func (m *Model) keepVulnerabilitySelectionInWindow() {
if len(m.snapshot.Vulnerabilities) == 0 {
return
}
if m.selectedVuln < m.vulnOffset {
m.selectedVuln = m.vulnOffset
} else if end := m.vulnerabilityVisibleEnd(m.vulnOffset); m.selectedVuln >= end {
m.selectedVuln = max(m.vulnOffset, end-1)
}
}
// statsView ports build_tui_stats_text + the version line appended in
// _update_stats_display: model, token/cost line, optional Caido URL, version.
func (m Model) modalView() string {
switch m.modal {
case modalHelp:
title := lipgloss.NewStyle().Bold(true).Foreground(green).Width(34).Align(lipgloss.Center).Render("Strix Help")
body := lipgloss.NewStyle().Foreground(textColor).Render("F1 Help\nCtrl+O Open viewer\nCtrl+Q/C Quit\nESC Stop Agent\nEnter Send / expand node\nCtrl+J Newline in message\nTab Switch panels\n↑/↓ Navigate tree\nDrag Select & copy text\nClick Expand/collapse tool")
content := title + "\n\n" + body
return lipgloss.NewStyle().Width(38).Border(lipgloss.RoundedBorder()).BorderForeground(green).Background(black).Padding(1, 2).Render(content)
case modalQuit:
// #quit_dialog: width 24, border round #333333, title #d4d4d4.
return m.confirmView("Quit Strix?", 24, dark, textColor)
case modalStop:
name := "agent"
if len(m.snapshot.Agents) > 0 {
name = m.snapshot.Agents[m.selectedAgent].Name
}
// #stop_agent_dialog: width 30, border round #a3a3a3, title #a3a3a3.
return m.confirmView("🛑 Stop '"+name+"'?", 30, mid, mid)
case modalConfirmMount:
return m.mountConfirmView()
case modalVulnerability:
if len(m.snapshot.Vulnerabilities) == 0 {
return ""
}
return m.vulnerabilityDetail()
}
return ""
}
func (m Model) confirmView(title string, width int, border, titleColor lipgloss.Color) string {
return m.confirmDialog(title, "", width, border, titleColor, red, "Yes", "No")
}
// mountConfirmView asks before a target-less scan mounts the working directory.
// It is a compact prompt docked in the corner of the live view: nothing is
// prepared until it is answered, and the directory is a workspace rather than a
// target, so the prompt is what the scan follows.
func (m Model) mountConfirmView() string {
width := min(52, max(20, m.width-4))
dir := strings.TrimSpace(m.snapshot.PendingMount)
if dir == "" {
dir = "the current directory"
}
title := render.Bold(amber).Render("△ Mount working directory?")
body := render.Col(white).Render(truncatePath(dir, width-4)) + "\n" +
render.Dim().Render("writable in the sandbox")
return m.cornerPrompt(title, body, width, "Confirm", "Cancel")
}
// truncatePath keeps the tail of a path visible, which is the part that
// identifies the directory.
func truncatePath(path string, width int) string {
if width <= 1 || lipgloss.Width(path) <= width {
return path
}
return "…" + ansi.TruncateLeft(path, lipgloss.Width(path)-width+1, "")
}
// cornerPrompt renders a compact two-button prompt for the corner of the live
// view, sized to its content rather than centered like the modal dialogs.
func (m Model) cornerPrompt(title, body string, width int, confirmLabel, cancelLabel string) string {
// Each label keeps its padding whether or not it is focused, so moving the
// choice repaints a background instead of shifting the pair sideways.
button := func(label string, focused bool, fill lipgloss.Color) string {
style := lipgloss.NewStyle().Bold(true)
if focused {
return style.Background(fill).Foreground(brightWhite).Render(" " + label + " ")
}
return style.Foreground(fill).Render(" " + label + " ")
}
yes := button(confirmLabel, m.modalChoice == 0, amber)
no := button(cancelLabel, m.modalChoice != 0, dim)
if m.modalChoice != 0 {
no = button(cancelLabel, true, lipgloss.Color("#3e3e3e"))
}
inner := lipgloss.NewStyle().Width(width - 4)
content := inner.Render(title) + "\n" + inner.Render(body) + "\n" +
inner.Align(lipgloss.Right).Render(yes+" "+no)
return lipgloss.NewStyle().Width(width-2).Border(lipgloss.RoundedBorder()).
BorderForeground(amber).Background(black).Padding(0, 1).Render(content)
}
// confirmDialog renders a two-button prompt. The focused button fills its
// background; body is optional detail shown between the title and the buttons.
func (m Model) confirmDialog(
title, body string,
width int,
border, titleColor, confirmColor lipgloss.Color,
confirmLabel, cancelLabel string,
) string {
// Two equal columns with a one-cell gutter. The buttons keep their columns
// whichever one is focused, so moving the choice repaints a background
// instead of shifting the row.
contentWidth := width - 4
inner := lipgloss.NewStyle().Width(contentWidth)
// Two columns share the content width with a one-cell gutter; the label
// carries a space on each side before it is centered in its column.
leftColumn := (contentWidth - 1) / 2
rightColumn := contentWidth - 1 - leftColumn
button := func(label string, column int, focused bool, fill lipgloss.Color) string {
style := lipgloss.NewStyle().Width(column).Align(lipgloss.Center).Bold(true)
if focused {
return style.Background(fill).Foreground(brightWhite).Render(" " + label + " ")
}
return style.Foreground(fill).Render(" " + label + " ")
}
yes := button(confirmLabel, leftColumn, m.modalChoice == 0, confirmColor)
no := button(cancelLabel, rightColumn, false, dim)
if m.modalChoice != 0 {
no = button(cancelLabel, rightColumn, true, lipgloss.Color("#3e3e3e"))
}
content := inner.Bold(true).Foreground(titleColor).Align(lipgloss.Center).Render(title)
if body != "" {
content += "\n\n" + inner.Render(body)
}
content += "\n\n" + inner.Align(lipgloss.Center).Render(yes+" "+no)
// Width() sets the content box, so the border's two columns come off it to
// keep the dialog the width the design calls for.
return lipgloss.NewStyle().Width(width - 2).Border(lipgloss.RoundedBorder()).BorderForeground(border).Background(black).Padding(1).Render(content)
}
// vulnerabilityBody ports VulnerabilityDetailScreen._render_vulnerability:
// the exact field order, labels, colors, and dict keys.
func vulnerabilityBody(v map[string]any) string {
fieldStyle := render.Bold(render.Field)
var b strings.Builder
b.WriteString("🐞 " + render.Bold(render.ReportHdr).Render("Vulnerability Report"))
field := func(label, value string) {
if value != "" {
b.WriteString("\n\n" + fieldStyle.Render(label+": ") + value)
}
}
field("Agent", render.StringValue(v["agent_name"]))
field("Title", render.StringValue(v["title"]))
if sev := render.StringValue(v["severity"]); sev != "" {
b.WriteString("\n\n" + fieldStyle.Render("Severity: ") +
lipgloss.NewStyle().Bold(true).Foreground(render.SeverityColor(sev)).Render(strings.ToUpper(sev)))
}
if score, ok := render.NumericValue(v["cvss"]); ok {
b.WriteString("\n\n" + fieldStyle.Render("CVSS Score: ") +
lipgloss.NewStyle().Bold(true).Foreground(render.CVSSColor(score)).Render(render.StringValue(v["cvss"])))
}
field("Target", render.StringValue(v["target"]))
if dep, ok := v["dependency_metadata"].(map[string]any); ok {
field("Package", render.StringValue(dep["package_name"]))
field("Ecosystem", render.StringValue(dep["package_ecosystem"]))
field("Installed Version", render.StringValue(dep["installed_version"]))
field("Fixed Version", render.StringValue(dep["fixed_version"]))
}
field("Endpoint", render.StringValue(v["endpoint"]))
field("Method", render.StringValue(v["method"]))
field("CVE", render.StringValue(v["cve"]))
field("CWE", render.StringValue(v["cwe"]))
if fe := render.StringValue(v["fix_effort"]); fe != "" {
field("Fix Effort", titleCase(fe))
}
if bd, ok := v["cvss_breakdown"].(map[string]any); ok && len(bd) > 0 {
if parts := render.CVSSVectorParts(bd); len(parts) > 0 {
b.WriteString("\n\n" + fieldStyle.Render("CVSS Vector: ") + render.Dim().Render(strings.Join(parts, "/")))
}
}
section := func(label, value string) {
if value != "" {
b.WriteString("\n\n" + fieldStyle.Render(label) + "\n" + value)
}
}
section("Description", render.StringValue(v["description"]))
section("Impact", render.StringValue(v["impact"]))
section("Technical Analysis", render.StringValue(v["technical_analysis"]))
section("Evidence", render.StringValue(v["evidence"]))
section("PoC Description", render.StringValue(v["poc_description"]))
if poc := render.StringValue(v["poc_script_code"]); poc != "" {
pocLang, pocCode := render.ParseFencedCode(poc)
b.WriteString("\n\n" + fieldStyle.Render("PoC Code") + "\n" + render.HighlightCode(pocCode, pocLang))
}
section("Remediation", render.StringValue(v["remediation_steps"]))
section("Assumptions", render.StringValue(v["assumptions"]))
return b.String()
}
func (m Model) vulnerabilityDialogSize() (width, height int) {
return min(m.width, min(110, max(40, m.width*85/100))), min(m.height, min(45, max(10, m.height*85/100)))
}
func (m *Model) resizeVulnerabilityViewport() {
if m.modal != modalVulnerability || len(m.snapshot.Vulnerabilities) == 0 {
return
}
width, height := m.vulnerabilityDialogSize()
innerWidth := max(1, width-8) // border plus three cells of horizontal padding
m.vulnViewport.Width = max(1, innerWidth-2) // right padding and one-cell scrollbar
m.vulnViewport.Height = max(1, height-9) // padding, one-row grid gutter, and two-row footer
m.vulnViewport.SetContent(wrapBlock(vulnerabilityBody(m.snapshot.Vulnerabilities[m.selectedVuln]), m.vulnViewport.Width))
m.vulnViewport.SetYOffset(m.vulnViewport.YOffset)
}
func (m Model) vulnerabilityScrollView() string {
view := m.vulnViewport.View()
if m.vulnViewport.TotalLineCount() <= m.vulnViewport.VisibleLineCount() {
return view + " "
}
height := m.vulnViewport.Height
thumbHeight := max(1, height*m.vulnViewport.VisibleLineCount()/m.vulnViewport.TotalLineCount())
thumbStart := int(m.vulnViewport.ScrollPercent() * float64(height-thumbHeight))
bar := make([]string, height)
for row := range bar {
cell := " "
if row >= thumbStart && row < thumbStart+thumbHeight {
cell = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040")).Render("█")
}
bar[row] = cell
}
return lipgloss.JoinHorizontal(lipgloss.Top, view, " ", strings.Join(bar, "\n"))
}
func (m Model) vulnerabilityDetail() string {
width, height := m.vulnerabilityDialogSize()
inner := max(1, width-8)
// Button row: right-aligned Copy / Done above a top rule (#vuln_detail_buttons).
rule := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a1a")).Render(strings.Repeat("─", max(1, inner)))
copyLabel := "Copy"
if m.vulnerabilityCopied {
copyLabel = "Copied!"
} else if m.vulnerabilityCopyError != "" {
copyLabel = "Copy failed"
}
copyButton := lipgloss.NewStyle().Foreground(lipgloss.Color("#525252"))
doneButton := lipgloss.NewStyle().Foreground(mid)
if m.modalChoice == 0 {
copyButton = copyButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1)
} else {
doneButton = doneButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1)
}
buttons := copyButton.Render(copyLabel) + " " + doneButton.Render("Done")
buttonRow := rule + "\n" + lipgloss.NewStyle().Width(inner).Align(lipgloss.Right).Render(buttons)
content := m.vulnerabilityScrollView() + "\n" + buttonRow
return lipgloss.NewStyle().Width(width-2).Height(height-2).Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#262626")).Background(lipgloss.Color("#0a0a0a")).Padding(2, 3).Render(content)
}
func (m *Model) startVulnerabilityCopy() tea.Cmd {
m.vulnerabilityCopied = false
m.vulnerabilityCopyError = ""
if m.selectedVuln < 0 || m.selectedVuln >= len(m.snapshot.Vulnerabilities) {
return nil
}
report := vulnerabilityMarkdownReport(m.snapshot.Vulnerabilities[m.selectedVuln])
return func() tea.Msg {
return vulnerabilityCopiedMsg{err: writeClipboard(report)}
}
}
// titleCase upper-cases the first letter of each word (Python str.title()).
+467
View File
@@ -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)
}
}
}
+152 -12
View File
@@ -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"
-43
View File
@@ -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")
-32
View File
@@ -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)
-71
View File
@@ -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)
@@ -1,35 +0,0 @@
from typing import Any, ClassVar
from rich.text import Text
from textual.widgets import Static
from .agent_message_renderer import AgentMessageRenderer
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
@register_tool_renderer
class RespondToUserRenderer(BaseToolRenderer):
"""Render a reply as the agent's own prose, not as a tool call.
``respond_to_user`` carries the message the user is meant to read, so it
gets the same markdown treatment as a plain assistant turn.
"""
tool_name: ClassVar[str] = "respond_to_user"
css_classes: ClassVar[list[str]] = ["tool-call", "respond-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
message = args.get("message", "")
text = Text()
if message:
text.append_text(AgentMessageRenderer.render_simple(message))
text.append("\n\n")
text.append("", style="#6b7280")
text.append("waiting for your reply", style="dim")
css_classes = cls.get_css_classes(tool_data.get("status", "unknown"))
return Static(text, classes=css_classes)
@@ -1,266 +0,0 @@
import re
from functools import cache
from typing import Any, ClassVar
from pygments.lexers import get_lexer_by_name
from pygments.styles import get_style_by_name
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
MAX_OUTPUT_LINES = 50
MAX_LINE_LENGTH = 200
STRIP_PATTERNS = [
r"^Chunk ID: [0-9a-f]+\s*$",
r"^Wall time: [\d.]+ seconds\s*$",
r"^Process exited with code -?\d+\s*$",
r"^Process running with session ID \d+\s*$",
r"^Original token count: \d+\s*$",
]
_EXIT_RE = re.compile(r"Process exited with code (-?\d+)")
_SESSION_RE = re.compile(r"Process running with session ID (\d+)")
_OUTPUT_HEADER = "\nOutput:\n"
_CONTROL_BYTES_TO_DROP = dict.fromkeys(
[b for b in range(0x20) if b not in (0x09, 0x0A)] + [0x7F],
None,
)
@cache
def _get_style_colors() -> dict[Any, str]:
style = get_style_by_name("native")
return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
def _parse_sdk_shell_result(result: Any) -> dict[str, Any]:
"""Translate the SDK's terminal-output string into the dict shape the
renderer's `_append_output` helper expects.
The SDK returns a header-prefixed string ending with `Output:\\n<actual>`.
We extract `content`, `exit_code`, and `session_id`; anything else (or a
non-string result) flows through unchanged so renderers can handle errors.
"""
if isinstance(result, dict):
return result
if not isinstance(result, str):
return {"content": "" if result is None else str(result)}
exit_match = _EXIT_RE.search(result)
session_match = _SESSION_RE.search(result)
idx = result.find(_OUTPUT_HEADER)
content = result[idx + len(_OUTPUT_HEADER) :] if idx >= 0 else result
parsed: dict[str, Any] = {"content": content}
if exit_match:
parsed["exit_code"] = int(exit_match.group(1))
if session_match:
parsed["session_id"] = int(session_match.group(1))
return parsed
def _truncate_line(line: str) -> str:
if len(line) > MAX_LINE_LENGTH:
return line[: MAX_LINE_LENGTH - 3] + "..."
return line
def _clean_output(output: str) -> str:
cleaned: str = Text.from_ansi(output).plain.translate(_CONTROL_BYTES_TO_DROP)
for pattern in STRIP_PATTERNS:
cleaned = re.sub(pattern, "", cleaned, flags=re.MULTILINE)
if cleaned.strip():
lines = cleaned.splitlines()
filtered_lines: list[str] = []
for line in lines:
if not filtered_lines and not line.strip():
continue
if line.strip() == "Output:":
continue
filtered_lines.append(line)
while filtered_lines and not filtered_lines[-1].strip():
filtered_lines.pop()
cleaned = "\n".join(filtered_lines)
return cleaned.strip()
def _format_output(output: str) -> Text:
text = Text()
lines = output.splitlines()
total_lines = len(lines)
head_count = MAX_OUTPUT_LINES // 2
tail_count = MAX_OUTPUT_LINES - head_count - 1
if total_lines <= MAX_OUTPUT_LINES:
display_lines = lines
truncated = False
hidden_count = 0
else:
display_lines = lines[:head_count]
truncated = True
hidden_count = total_lines - head_count - tail_count
for i, line in enumerate(display_lines):
text.append(" ")
text.append(_truncate_line(line), style="dim")
if i < len(display_lines) - 1 or truncated:
text.append("\n")
if truncated:
text.append(f" ... {hidden_count} lines truncated ...", style="dim italic")
text.append("\n")
tail_lines = lines[-tail_count:]
for i, line in enumerate(tail_lines):
text.append(" ")
text.append(_truncate_line(line), style="dim")
if i < len(tail_lines) - 1:
text.append("\n")
return text
def _get_token_color(token_type: Any) -> str | None:
colors = _get_style_colors()
while token_type:
if token_type in colors:
return colors[token_type]
token_type = token_type.parent
return None
def _highlight_bash(code: str) -> Text:
lexer = get_lexer_by_name("bash")
text = Text()
for token_type, token_value in lexer.get_tokens(code):
if not token_value:
continue
color = _get_token_color(token_type)
text.append(token_value, style=color)
return text
def _append_output(text: Text, parsed: dict[str, Any], tool_status: str) -> None:
raw_output = parsed.get("content", "") or ""
output = _clean_output(raw_output) if isinstance(raw_output, str) else ""
exit_code = parsed.get("exit_code")
if tool_status == "running":
if output:
text.append("\n")
text.append_text(_format_output(output))
return
if not output:
if exit_code is not None and exit_code != 0:
text.append("\n")
text.append(f" exit {exit_code}", style="dim #ef4444")
return
text.append("\n")
text.append_text(_format_output(output))
if exit_code is not None and exit_code != 0:
text.append("\n")
text.append(f" exit {exit_code}", style="dim #ef4444")
def _build_terminal_content(
*,
prompt: str,
prompt_style: str,
command: str,
parsed_result: dict[str, Any] | None,
tool_status: str,
meta: str | None = None,
) -> Text:
text = Text()
text.append(">_", style="dim")
text.append(" ")
if not command.strip():
text.append("getting logs...", style="dim")
else:
text.append(prompt, style=prompt_style)
text.append(" ")
text.append_text(_highlight_bash(command))
if meta:
text.append(f" {meta}", style="dim")
if parsed_result is not None:
_append_output(text, parsed_result, tool_status)
return text
@register_tool_renderer
class ExecCommandRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "exec_command"
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
status = tool_data.get("status", "unknown")
result = tool_data.get("result")
cmd = str(args.get("cmd", ""))
workdir = args.get("workdir")
tty = bool(args.get("tty"))
meta_parts: list[str] = []
if workdir:
meta_parts.append(f"cwd:{workdir}")
if tty:
meta_parts.append("tty")
meta = ", ".join(meta_parts) if meta_parts else None
parsed = _parse_sdk_shell_result(result) if result is not None else None
content = _build_terminal_content(
prompt="$",
prompt_style="#22c55e",
command=cmd,
parsed_result=parsed,
tool_status=status,
meta=meta,
)
return Static(content, classes=cls.get_css_classes(status))
@register_tool_renderer
class WriteStdinRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "write_stdin"
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
status = tool_data.get("status", "unknown")
result = tool_data.get("result")
chars = str(args.get("chars", ""))
session_id = args.get("session_id")
meta = f"session #{session_id}" if session_id is not None else None
parsed = _parse_sdk_shell_result(result) if result is not None else None
content = _build_terminal_content(
prompt=">>>",
prompt_style="#3b82f6",
command=chars,
parsed_result=parsed,
tool_status=status,
meta=meta,
)
return Static(content, classes=cls.get_css_classes(status))
@@ -1,31 +0,0 @@
from typing import Any, ClassVar
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
@register_tool_renderer
class ThinkRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "think"
css_classes: ClassVar[list[str]] = ["tool-call", "thinking-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
thought = args.get("thought", "")
text = Text()
text.append("🧠 ")
text.append("Thinking", style="bold #a855f7")
text.append("\n ")
if thought:
text.append(thought, style="italic dim")
else:
text.append("Thinking...", style="italic dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@@ -1,225 +0,0 @@
from typing import Any, ClassVar
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
STATUS_MARKERS: dict[str, str] = {
"pending": "[ ]",
"in_progress": "[~]",
"done": "[•]",
}
def _format_todo_lines(text: Text, result: dict[str, Any]) -> None:
todos = result.get("todos")
if not isinstance(todos, list) or not todos:
text.append("\n ")
text.append("No todos", style="dim")
return
for todo in todos:
status = todo.get("status", "pending")
marker = STATUS_MARKERS.get(status, STATUS_MARKERS["pending"])
title = todo.get("title", "").strip() or "(untitled)"
text.append("\n ")
text.append(marker)
text.append(" ")
if status == "done":
text.append(title, style="dim strike")
elif status == "in_progress":
text.append(title, style="italic")
else:
text.append(title)
@register_tool_renderer
class CreateTodoRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "create_todo"
css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
result = tool_data.get("result")
text = Text()
text.append("📋 ")
text.append("Todo", style="bold #a78bfa")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif result and isinstance(result, dict):
if result.get("success"):
_format_todo_lines(text, result)
else:
error = result.get("error", "Failed to create todo")
text.append("\n ")
text.append(error, style="#ef4444")
else:
text.append("\n ")
text.append("Creating...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class ListTodosRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "list_todos"
css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
result = tool_data.get("result")
text = Text()
text.append("📋 ")
text.append("Todos", style="bold #a78bfa")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif result and isinstance(result, dict):
if result.get("success"):
_format_todo_lines(text, result)
else:
error = result.get("error", "Unable to list todos")
text.append("\n ")
text.append(error, style="#ef4444")
else:
text.append("\n ")
text.append("Loading...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class UpdateTodoRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "update_todo"
css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
result = tool_data.get("result")
text = Text()
text.append("📋 ")
text.append("Todo Updated", style="bold #a78bfa")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif result and isinstance(result, dict):
if result.get("success"):
_format_todo_lines(text, result)
else:
error = result.get("error", "Failed to update todo")
text.append("\n ")
text.append(error, style="#ef4444")
else:
text.append("\n ")
text.append("Updating...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class MarkTodoDoneRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "mark_todo_done"
css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
result = tool_data.get("result")
text = Text()
text.append("📋 ")
text.append("Todo Completed", style="bold #a78bfa")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif result and isinstance(result, dict):
if result.get("success"):
_format_todo_lines(text, result)
else:
error = result.get("error", "Failed to mark todo done")
text.append("\n ")
text.append(error, style="#ef4444")
else:
text.append("\n ")
text.append("Marking done...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class MarkTodoPendingRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "mark_todo_pending"
css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
result = tool_data.get("result")
text = Text()
text.append("📋 ")
text.append("Todo Reopened", style="bold #f59e0b")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif result and isinstance(result, dict):
if result.get("success"):
_format_todo_lines(text, result)
else:
error = result.get("error", "Failed to reopen todo")
text.append("\n ")
text.append(error, style="#ef4444")
else:
text.append("\n ")
text.append("Reopening...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@register_tool_renderer
class DeleteTodoRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "delete_todo"
css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
result = tool_data.get("result")
text = Text()
text.append("📋 ")
text.append("Todo Removed", style="bold #94a3b8")
if isinstance(result, str) and result.strip():
text.append("\n ")
text.append(result.strip(), style="dim")
elif result and isinstance(result, dict):
if result.get("success"):
_format_todo_lines(text, result)
else:
error = result.get("error", "Failed to remove todo")
text.append("\n ")
text.append(error, style="#ef4444")
else:
text.append("\n ")
text.append("Removing...", style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
@@ -1,29 +0,0 @@
from rich.text import Text
class UserMessageRenderer:
@classmethod
def render_simple(cls, content: str) -> Text:
if not content:
return Text()
return cls._format_user_message(content)
@classmethod
def _format_user_message(cls, content: str) -> Text:
text = Text()
text.append("", style="#3b82f6")
text.append(" ")
text.append("You:", style="bold")
text.append("\n")
lines = content.split("\n")
for i, line in enumerate(lines):
if i > 0:
text.append("\n")
text.append("", style="#3b82f6")
text.append(" ")
text.append(line)
return text
@@ -1,29 +0,0 @@
from typing import Any, ClassVar
from rich.text import Text
from textual.widgets import Static
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
@register_tool_renderer
class WebSearchRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "web_search"
css_classes: ClassVar[list[str]] = ["tool-call", "web-search-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
query = args.get("query", "")
text = Text()
text.append("🌐 ")
text.append("Searching the web...", style="bold #60a5fa")
if query:
text.append("\n ")
text.append(query, style="dim")
css_classes = cls.get_css_classes("completed")
return Static(text, classes=css_classes)
+392
View File
@@ -0,0 +1,392 @@
"""Launch and supervise the Bubble Tea TUI."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import shutil
import sys
from copy import deepcopy
from pathlib import Path
from typing import TYPE_CHECKING, Any
from strix.config import load_settings, persist_current
from strix.core.agents import AgentCoordinator
from strix.core.hooks import BudgetExceededError
from strix.core.runner import run_strix_scan
from strix.interface.scan_setup import (
build_targets_info,
preflight_model_connection,
prepare_run,
telemetry_start,
)
from strix.interface.tui.backend import TuiBackendServer, TuiController
from strix.interface.tui.backend.live_view import TuiLiveView
from strix.interface.tui.sidecar import (
check_return_code,
child_environment,
launch_tui_process,
package_version,
terminate_process,
tui_executable,
tui_source_dir,
wait_process,
)
from strix.report.state import ReportState, set_global_report_state
from strix.utils.resource_paths import get_strix_resource_path
if TYPE_CHECKING:
import argparse
import socket
import subprocess
logger = logging.getLogger(__name__)
class GoTuiPreActivationError(RuntimeError):
"""A sidecar failure raised before the Go TUI activates."""
class GoTuiRuntime:
def __init__(self, args: argparse.Namespace) -> None:
self.args = args
self.live_view = TuiLiveView()
self.coordinator = AgentCoordinator()
self.report_state: ReportState | None = None
self.scan_config: dict[str, Any] = {}
self.scan_task: asyncio.Task[None] | None = None
self.scan_error: BaseException | None = None
self._last_sync_fingerprint = ""
self._error_noted_agents: set[str] = set()
self.controller = TuiController(
args,
live_view=self.live_view,
coordinator=self.coordinator,
on_start=self.start_from_setup,
on_quit=self.quit,
)
self.server = TuiBackendServer(self.controller)
def init_run_state(self) -> None:
self.scan_config = {
"scan_id": self.args.run_name,
"targets": self.args.targets_info,
"user_instructions": self.args.instruction or "",
"run_name": self.args.run_name,
"diff_scope": self.args.diff_scope,
"scan_mode": self.args.scan_mode,
"non_interactive": False,
"local_sources": self.args.local_sources or [],
"scope_mode": self.args.scope_mode,
"diff_base": self.args.diff_base,
"resume_instruction": self.args.user_explicit_instruction or "",
"workspace_mount": getattr(self.args, "workspace_mount", None) or "",
"workspace_subdir": getattr(self.args, "workspace_subdir", None) or "",
}
self.report_state = ReportState(self.scan_config["run_name"])
self.report_state.hydrate_from_run_dir()
self.report_state.set_scan_config(self.scan_config)
self.report_state.save_run_data()
set_global_report_state(self.report_state)
self.live_view.hydrate_from_run_dir(self.report_state.get_run_dir())
self.controller.set_runtime(
report_state=self.report_state,
scan_loop=asyncio.get_running_loop(),
)
self.report_state.vulnerability_found_callback = lambda _report: (
self.controller.notify_changed()
)
self.controller.notify_changed()
async def start_from_setup(self, verify: bool = True) -> None:
candidate = deepcopy(self.args)
candidate.scan_mode = self.controller.scan_mode
candidate.instruction = self.controller.instruction
# Held apart from instruction, which prepare_run prefixes with the
# diff-scope preamble, so the transcript can show what was typed.
candidate.user_instruction = self.controller.instruction or None
candidate.max_budget_usd = self.controller.max_budget_usd
candidate.max_turns = self.controller.max_turns
candidate.scope_mode = self.controller.scope_mode
candidate.diff_base = self.controller.diff_base
existing_targets = [
str(target["original"])
for target in candidate.targets_info
if isinstance(target, dict) and target.get("original")
]
targets_changed = self.controller.targets != existing_targets
model = (load_settings().llm.model or "").strip()
# A bare prompt launches optimistically: it skips the network preflight
# and lets any model error surface once the agent starts, like a coding
# agent. A named target keeps the upfront check.
if verify:
try:
await preflight_model_connection(model)
except Exception as exc:
logger.exception("Go TUI setup model preflight failed")
raise RuntimeError(f"Model connection failed: {exc}") from exc
# A confirmed target-less launch mounts the working directory for the
# agent to work in, without making it a scan target.
candidate.workspace_mount = self.controller.workspace_mount
if targets_changed:
# Rebuild the full typed set so path canonicalization and local
# deduplication match the CLI.
candidate.target = list(self.controller.targets)
candidate.target_list = []
build_targets_info(candidate)
prepare_run(candidate)
telemetry_start(candidate)
vars(self.args).update(vars(candidate))
self.init_run_state()
self.start_scan()
async def prepare_and_start(self) -> None:
"""Prepare a directly-launched scan once the TUI is on screen.
The model round trip and run preparation run here rather than before
launch so the interface appears immediately.
"""
model = (load_settings().llm.model or "").strip()
try:
await preflight_model_connection(model)
persist_current()
prepare_run(self.args)
telemetry_start(self.args)
except Exception as exc:
logger.exception("Go TUI scan preparation failed")
self.controller.fail_preparation(str(exc))
return
self.controller.scan_state = "running"
self.init_run_state()
self.start_scan()
def start_scan(self) -> None:
if self.scan_task is None:
self.scan_task = asyncio.create_task(self._run_scan())
async def _run_scan(self) -> None:
image = str(load_settings().runtime.image or "strix-sandbox:latest")
try:
await run_strix_scan(
scan_config=self.scan_config,
scan_id=self.scan_config["run_name"],
image=image,
local_sources=self.args.local_sources or [],
coordinator=self.coordinator,
interactive=True,
max_turns=self.args.max_turns,
max_budget_usd=self.args.max_budget_usd,
event_sink=self.capture_event,
)
await self._sync_agent_state()
if self.controller.scan_state == "running":
self.controller.scan_state = "stopped"
except (asyncio.CancelledError, BudgetExceededError):
report_status = (
self.report_state.run_record.get("status")
if self.report_state is not None
else None
)
self.controller.scan_state = "completed" if report_status == "completed" else "stopped"
except Exception as exc:
logger.exception("Go TUI scan failed")
self.scan_error = exc
self.controller.error = str(exc)
self.controller.scan_state = "failed"
finally:
with contextlib.suppress(Exception):
await self._sync_agent_state()
self.controller.notify_changed()
def capture_event(self, agent_id: str, event: Any) -> None:
self.live_view.ingest_sdk_event(agent_id, event)
self.controller.notify_changed()
async def _sync_agent_state(self) -> bool:
parent_of, statuses, names, errors = await self.coordinator.graph_snapshot()
changed = False
for agent_id, status in statuses.items():
error = errors.get(agent_id)
changed = (
self.live_view.upsert_agent(
agent_id,
name=names.get(agent_id, agent_id),
parent_id=parent_of.get(agent_id),
status=str(status),
error_message=error,
)
or changed
)
if status in {"failed", "crashed"} and error:
if agent_id not in self._error_noted_agents:
self._error_noted_agents.add(agent_id)
self.live_view.record_agent_error(agent_id, error)
changed = True
else:
self._error_noted_agents.discard(agent_id)
# The user's opening message waits for the root agent to exist, which is
# the first thing this sync learns about.
changed = self.live_view.flush_user_instruction() or changed
roots = [agent_id for agent_id, parent_id in parent_of.items() if parent_id is None]
root_id = roots[0] if roots else None
root_status = statuses.get(root_id) if root_id is not None else None
report_status = (
self.report_state.run_record.get("status") if self.report_state is not None else None
)
scan_state = self.controller.scan_state
if root_status in {"failed", "crashed"}:
scan_state = "failed"
if root_id is not None and errors.get(root_id):
self.controller.error = errors[root_id]
elif scan_state != "failed":
if report_status == "completed":
scan_state = "completed"
elif root_status == "stopped":
scan_state = "stopped"
elif root_status == "completed":
scan_state = "failed"
self.controller.error = "Scan ended without a completed report"
if scan_state != self.controller.scan_state:
self.controller.scan_state = scan_state
changed = True
return changed
def _runtime_sync_fingerprint(self) -> str:
usage: dict[str, Any] = {}
vulnerabilities: list[object] = []
if self.report_state is not None:
usage = dict(self.report_state.get_total_llm_usage())
vulnerabilities = [
report.get("id", index) if isinstance(report, dict) else index
for index, report in enumerate(self.report_state.vulnerability_reports)
]
return json.dumps(
{
"scan_state": self.controller.scan_state,
"usage": usage,
"vulnerabilities": vulnerabilities,
},
default=str,
sort_keys=True,
separators=(",", ":"),
)
async def sync_state(self) -> None:
while True:
if self.scan_task is not None and not self.scan_task.done():
try:
changed = await self._sync_agent_state()
except Exception as exc:
logger.exception("Go TUI agent-state sync failed")
self.controller.error = f"Agent-state sync failed: {exc}"
changed = True
fingerprint = self._runtime_sync_fingerprint()
if fingerprint != self._last_sync_fingerprint:
self._last_sync_fingerprint = fingerprint
changed = True
if changed:
self.controller.notify_changed()
await asyncio.sleep(0.5)
async def quit(self) -> None:
self.controller.close_viewer()
self.coordinator.mark_shutting_down()
scan_task = self.scan_task
if scan_task is not None:
if not scan_task.done():
scan_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await scan_task
@staticmethod
def binary_command() -> list[str]:
source = tui_source_dir()
# A checkout may also contain a stale wheel/build sidecar. Running the
# current source is the deterministic development choice.
if (source / "go.mod").is_file() and shutil.which("go"):
return ["go", "run", "./cmd/strix-tui"]
packaged = get_strix_resource_path("bin", tui_executable())
if packaged.is_file():
return [str(packaged)]
raise RuntimeError(
"Bubble Tea TUI binary not found. Reinstall Strix from an official platform wheel."
)
@staticmethod
async def _cancel_tasks(*tasks: asyncio.Task[None] | None) -> None:
for task in tasks:
if task is None:
continue
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def run(self) -> None:
# Redirect the process's sys.stdout/sys.stderr while the TUI runs so
# logging handlers created during the scan never paint over the Go
# TUI's alt screen. The child still inherits the real terminal fds;
# only the Python-level bindings change.
original_stdout = sys.stdout
original_stderr = sys.stderr
output_sink = Path(os.devnull).open("a", buffering=1) # noqa: SIM115
sys.stdout = output_sink
sys.stderr = output_sink
backend_socket: socket.socket | None = None
sync_task: asyncio.Task[None] | None = None
prepare_task: asyncio.Task[None] | None = None
process: asyncio.subprocess.Process | subprocess.Popen[bytes] | None = None
try:
env = child_environment()
env["STRIX_VERSION"] = package_version()
command = self.binary_command()
cwd = str(tui_source_dir()) if command[:2] == ["go", "run"] else None
if cwd is not None:
# go run compiles the sidecar when the build cache is cold, so
# tell the terminal why nothing is on screen yet.
print(
"\x1b[2mCompiling the TUI from source (cached after the first run)...\x1b[0m",
file=original_stdout,
flush=True,
)
process, backend_socket = await launch_tui_process(command, env, cwd)
await self.server.start(backend_socket)
if not self.controller.setup_mode:
self.controller.begin_preparation()
prepare_task = asyncio.create_task(self.prepare_and_start())
sync_task = asyncio.create_task(self.sync_state())
return_code = await wait_process(process)
check_return_code(return_code)
except Exception as exc:
await terminate_process(process)
if not self.server.activated:
raise GoTuiPreActivationError(str(exc)) from exc
raise
except BaseException:
await terminate_process(process)
raise
finally:
try:
if backend_socket is not None:
backend_socket.close()
await self._cancel_tasks(prepare_task, sync_task)
await self.quit()
await self.server.close()
finally:
sys.stdout = original_stdout
sys.stderr = original_stderr
output_sink.close()
# Mirror run_tui: surface the captured scan failure once the app has
# exited cleanly so the CLI reports it instead of exiting 0.
if self.scan_error is not None:
raise self.scan_error
async def run_go_tui(args: argparse.Namespace) -> None:
await GoTuiRuntime(args).run()
+191
View File
@@ -0,0 +1,191 @@
"""Launch, authenticate, and supervise the Go TUI sidecar process."""
from __future__ import annotations
import asyncio
import contextlib
import hmac
import os
import secrets
import socket
import subprocess
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import Any
_WINDOWS_AUTH_TIMEOUT = 10.0
_PROCESS_EXIT_TIMEOUT = 5.0
_SENSITIVE_ENV_SUFFIXES = ("_API_KEY", "_ACCESS_KEY")
_SENSITIVE_ENV_PARTS = frozenset(
{"CREDENTIAL", "CREDENTIALS", "PASSWORD", "SECRET", "SECRETS", "TOKEN", "TOKENS"}
)
_SENSITIVE_ENV_NAMES = {
"AWS_ACCESS_KEY_ID",
"GOOGLE_APPLICATION_CREDENTIALS",
"LLM_API_KEY",
"STRIX_TUI_ADDR",
"STRIX_TUI_FD",
"STRIX_TUI_TOKEN",
}
def tui_executable() -> str:
return "strix-tui.exe" if os.name == "nt" else "strix-tui"
def project_root() -> Path:
return Path(__file__).resolve().parents[3]
def tui_source_dir() -> Path:
return Path(__file__).resolve().parent
def child_environment() -> dict[str, str]:
"""Copy only non-secret process state needed by the terminal sidecar."""
child: dict[str, str] = {}
for key, value in os.environ.items():
normalized = key.upper()
if normalized in _SENSITIVE_ENV_NAMES:
continue
if normalized.endswith(_SENSITIVE_ENV_SUFFIXES):
continue
if set(normalized.split("_")) & _SENSITIVE_ENV_PARTS:
continue
child[key] = value
return child
def _recv_exactly(connection: socket.socket, size: int) -> bytes:
chunks: list[bytes] = []
remaining = size
while remaining:
chunk = connection.recv(remaining)
if not chunk:
raise ConnectionError("TUI IPC peer closed during authentication")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def _authenticate_connection(
connection: socket.socket,
address: tuple[Any, ...],
expected_token: str,
) -> None:
if address[0] not in {"127.0.0.1", "::1"}:
raise ConnectionError("TUI IPC connection did not originate from loopback")
connection.settimeout(_WINDOWS_AUTH_TIMEOUT)
supplied = _recv_exactly(connection, len(expected_token)).decode("ascii")
if not hmac.compare_digest(supplied, expected_token):
raise PermissionError("TUI IPC authentication failed")
connection.settimeout(None)
def _accept_authenticated_connection(
listener: socket.socket,
expected_token: str,
) -> socket.socket:
"""Accept and authenticate the one Windows loopback connection."""
listener.settimeout(_WINDOWS_AUTH_TIMEOUT)
connection, address = listener.accept()
try:
_authenticate_connection(connection, address, expected_token)
except BaseException:
connection.close()
raise
return connection
async def wait_process(
process: asyncio.subprocess.Process | subprocess.Popen[bytes],
) -> int:
if isinstance(process, asyncio.subprocess.Process):
return await process.wait()
return await asyncio.to_thread(process.wait)
async def terminate_process(
process: asyncio.subprocess.Process | subprocess.Popen[bytes] | None,
) -> None:
if process is None or process.returncode is not None:
return
with contextlib.suppress(ProcessLookupError):
process.terminate()
wait_task = asyncio.create_task(wait_process(process))
try:
await asyncio.wait_for(asyncio.shield(wait_task), _PROCESS_EXIT_TIMEOUT)
except TimeoutError:
with contextlib.suppress(ProcessLookupError):
process.kill()
await asyncio.wait_for(asyncio.shield(wait_task), _PROCESS_EXIT_TIMEOUT)
async def launch_tui_process(
command: list[str],
env: dict[str, str],
cwd: str | None,
) -> tuple[asyncio.subprocess.Process | subprocess.Popen[bytes], socket.socket]:
if os.name == "nt":
return await _launch_windows_tui_process(command, env, cwd)
return await _launch_posix_tui_process(command, env, cwd)
async def _launch_posix_tui_process(
command: list[str],
env: dict[str, str],
cwd: str | None,
) -> tuple[asyncio.subprocess.Process, socket.socket]:
backend_socket, child_socket = socket.socketpair()
try:
env["STRIX_TUI_FD"] = str(child_socket.fileno())
process = await asyncio.create_subprocess_exec(
*command, env=env, cwd=cwd, pass_fds=(child_socket.fileno(),)
)
except BaseException:
backend_socket.close()
raise
finally:
child_socket.close()
return process, backend_socket
async def _launch_windows_tui_process(
command: list[str],
env: dict[str, str],
cwd: str | None,
) -> tuple[subprocess.Popen[bytes], socket.socket]:
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
windows_process: subprocess.Popen[bytes] | None = None
connection: socket.socket | None = None
try:
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", 0))
listener.listen(1)
token = secrets.token_hex(32)
host, port = listener.getsockname()[:2]
env.update({"STRIX_TUI_ADDR": f"{host}:{port}", "STRIX_TUI_TOKEN": token})
windows_process = subprocess.Popen(command, env=env, cwd=cwd) # noqa: S603
connection = await asyncio.to_thread(_accept_authenticated_connection, listener, token)
except BaseException:
await terminate_process(windows_process)
raise
finally:
listener.close()
assert windows_process is not None and connection is not None
return windows_process, connection
def check_return_code(return_code: int) -> None:
if return_code != 0:
raise RuntimeError(f"Bubble Tea TUI exited with status {return_code}")
def package_version() -> str:
"""Report the installed package version for the Go splash/stats
("dev" when metadata is unavailable)."""
try:
return version("strix-agent")
except PackageNotFoundError:
return "dev"
+13 -12
View File
@@ -97,19 +97,19 @@ def _is_newer(latest: str, current: str) -> bool:
def _fetch_latest_version() -> str | None:
try:
if is_binary_install():
response = requests.get(
with requests.get(
f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
tag = response.json().get("tag_name", "")
) as response:
response.raise_for_status()
tag = response.json().get("tag_name", "")
return tag.lstrip("v") or None
response = requests.get(
with requests.get(
f"https://pypi.org/pypi/{PYPI_PACKAGE}/json",
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
version = response.json().get("info", {}).get("version")
) as response:
response.raise_for_status()
version = response.json().get("info", {}).get("version")
return str(version) if version else None
except Exception: # noqa: BLE001
logger.debug("update check failed", exc_info=True)
@@ -119,12 +119,13 @@ def _fetch_latest_version() -> str | None:
def _fetch_asset_digest(version: str, filename: str) -> str | None:
"""Return the expected sha256 (hex) for a release asset, if the API provides one."""
try:
response = requests.get(
with requests.get(
f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{version}",
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
for asset in response.json().get("assets", []):
) as response:
response.raise_for_status()
assets = response.json().get("assets", [])
for asset in assets:
if asset.get("name") == filename:
digest = asset.get("digest") or ""
if digest.startswith("sha256:"):

Some files were not shown because too many files have changed in this diff Show More