strix-agent 0.4.0__py3-none-any.whl → 0.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- strix/agents/StrixAgent/strix_agent.py +3 -3
- strix/agents/StrixAgent/system_prompt.jinja +30 -26
- strix/agents/base_agent.py +159 -75
- strix/agents/state.py +5 -2
- strix/config/__init__.py +12 -0
- strix/config/config.py +172 -0
- strix/interface/assets/tui_styles.tcss +195 -230
- strix/interface/cli.py +16 -41
- strix/interface/main.py +151 -74
- strix/interface/streaming_parser.py +119 -0
- strix/interface/tool_components/__init__.py +4 -0
- strix/interface/tool_components/agent_message_renderer.py +190 -0
- strix/interface/tool_components/agents_graph_renderer.py +54 -38
- strix/interface/tool_components/base_renderer.py +68 -36
- strix/interface/tool_components/browser_renderer.py +106 -91
- strix/interface/tool_components/file_edit_renderer.py +117 -36
- strix/interface/tool_components/finish_renderer.py +43 -10
- strix/interface/tool_components/notes_renderer.py +63 -38
- strix/interface/tool_components/proxy_renderer.py +133 -92
- strix/interface/tool_components/python_renderer.py +121 -8
- strix/interface/tool_components/registry.py +19 -12
- strix/interface/tool_components/reporting_renderer.py +196 -28
- strix/interface/tool_components/scan_info_renderer.py +22 -19
- strix/interface/tool_components/terminal_renderer.py +270 -90
- strix/interface/tool_components/thinking_renderer.py +8 -6
- strix/interface/tool_components/todo_renderer.py +225 -0
- strix/interface/tool_components/user_message_renderer.py +26 -19
- strix/interface/tool_components/web_search_renderer.py +7 -6
- strix/interface/tui.py +907 -262
- strix/interface/utils.py +236 -4
- strix/llm/__init__.py +6 -2
- strix/llm/config.py +8 -5
- strix/llm/dedupe.py +217 -0
- strix/llm/llm.py +209 -356
- strix/llm/memory_compressor.py +6 -5
- strix/llm/utils.py +17 -8
- strix/runtime/__init__.py +12 -3
- strix/runtime/docker_runtime.py +121 -202
- strix/runtime/tool_server.py +55 -95
- strix/skills/README.md +64 -0
- strix/skills/__init__.py +110 -0
- strix/{prompts → skills}/frameworks/nextjs.jinja +26 -0
- strix/skills/scan_modes/deep.jinja +145 -0
- strix/skills/scan_modes/quick.jinja +63 -0
- strix/skills/scan_modes/standard.jinja +91 -0
- strix/telemetry/README.md +38 -0
- strix/telemetry/__init__.py +7 -1
- strix/telemetry/posthog.py +137 -0
- strix/telemetry/tracer.py +194 -54
- strix/tools/__init__.py +11 -4
- strix/tools/agents_graph/agents_graph_actions.py +20 -21
- strix/tools/agents_graph/agents_graph_actions_schema.xml +8 -8
- strix/tools/browser/browser_actions.py +10 -6
- strix/tools/browser/browser_actions_schema.xml +6 -1
- strix/tools/browser/browser_instance.py +96 -48
- strix/tools/browser/tab_manager.py +121 -102
- strix/tools/context.py +12 -0
- strix/tools/executor.py +63 -4
- strix/tools/file_edit/file_edit_actions.py +6 -3
- strix/tools/file_edit/file_edit_actions_schema.xml +45 -3
- strix/tools/finish/finish_actions.py +80 -105
- strix/tools/finish/finish_actions_schema.xml +121 -14
- strix/tools/notes/notes_actions.py +6 -33
- strix/tools/notes/notes_actions_schema.xml +50 -46
- strix/tools/proxy/proxy_actions.py +14 -2
- strix/tools/proxy/proxy_actions_schema.xml +0 -1
- strix/tools/proxy/proxy_manager.py +28 -16
- strix/tools/python/python_actions.py +2 -2
- strix/tools/python/python_actions_schema.xml +9 -1
- strix/tools/python/python_instance.py +39 -37
- strix/tools/python/python_manager.py +43 -31
- strix/tools/registry.py +73 -12
- strix/tools/reporting/reporting_actions.py +218 -31
- strix/tools/reporting/reporting_actions_schema.xml +256 -8
- strix/tools/terminal/terminal_actions.py +2 -2
- strix/tools/terminal/terminal_actions_schema.xml +6 -0
- strix/tools/terminal/terminal_manager.py +41 -30
- strix/tools/thinking/thinking_actions_schema.xml +27 -25
- strix/tools/todo/__init__.py +18 -0
- strix/tools/todo/todo_actions.py +568 -0
- strix/tools/todo/todo_actions_schema.xml +225 -0
- strix/utils/__init__.py +0 -0
- strix/utils/resource_paths.py +13 -0
- {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/METADATA +90 -65
- strix_agent-0.6.2.dist-info/RECORD +134 -0
- {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/WHEEL +1 -1
- strix/llm/request_queue.py +0 -87
- strix/prompts/README.md +0 -64
- strix/prompts/__init__.py +0 -109
- strix_agent-0.4.0.dist-info/RECORD +0 -118
- /strix/{prompts → skills}/cloud/.gitkeep +0 -0
- /strix/{prompts → skills}/coordination/root_agent.jinja +0 -0
- /strix/{prompts → skills}/custom/.gitkeep +0 -0
- /strix/{prompts → skills}/frameworks/fastapi.jinja +0 -0
- /strix/{prompts → skills}/protocols/graphql.jinja +0 -0
- /strix/{prompts → skills}/reconnaissance/.gitkeep +0 -0
- /strix/{prompts → skills}/technologies/firebase_firestore.jinja +0 -0
- /strix/{prompts → skills}/technologies/supabase.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/authentication_jwt.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/broken_function_level_authorization.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/business_logic.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/csrf.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/idor.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/information_disclosure.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/insecure_file_uploads.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/mass_assignment.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/open_redirect.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/path_traversal_lfi_rfi.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/race_conditions.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/rce.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/sql_injection.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/ssrf.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/subdomain_takeover.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/xss.jinja +0 -0
- /strix/{prompts → skills}/vulnerabilities/xxe.jinja +0 -0
- {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/entry_points.txt +0 -0
- {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info/licenses}/LICENSE +0 -0
strix/interface/tui.py
CHANGED
|
@@ -2,14 +2,13 @@ import argparse
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import atexit
|
|
4
4
|
import logging
|
|
5
|
-
import random
|
|
6
5
|
import signal
|
|
7
6
|
import sys
|
|
8
7
|
import threading
|
|
9
8
|
from collections.abc import Callable
|
|
10
9
|
from importlib.metadata import PackageNotFoundError
|
|
11
10
|
from importlib.metadata import version as pkg_version
|
|
12
|
-
from typing import TYPE_CHECKING, Any, ClassVar
|
|
11
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
13
12
|
|
|
14
13
|
|
|
15
14
|
if TYPE_CHECKING:
|
|
@@ -17,7 +16,6 @@ if TYPE_CHECKING:
|
|
|
17
16
|
|
|
18
17
|
from rich.align import Align
|
|
19
18
|
from rich.console import Group
|
|
20
|
-
from rich.markup import escape as rich_escape
|
|
21
19
|
from rich.panel import Panel
|
|
22
20
|
from rich.style import Style
|
|
23
21
|
from rich.text import Text
|
|
@@ -31,15 +29,11 @@ from textual.widgets import Button, Label, Static, TextArea, Tree
|
|
|
31
29
|
from textual.widgets.tree import TreeNode
|
|
32
30
|
|
|
33
31
|
from strix.agents.StrixAgent import StrixAgent
|
|
34
|
-
from strix.interface.utils import
|
|
32
|
+
from strix.interface.utils import build_tui_stats_text
|
|
35
33
|
from strix.llm.config import LLMConfig
|
|
36
34
|
from strix.telemetry.tracer import Tracer, set_global_tracer
|
|
37
35
|
|
|
38
36
|
|
|
39
|
-
def escape_markup(text: str) -> str:
|
|
40
|
-
return cast("str", rich_escape(text))
|
|
41
|
-
|
|
42
|
-
|
|
43
37
|
def get_package_version() -> str:
|
|
44
38
|
try:
|
|
45
39
|
return pkg_version("strix-agent")
|
|
@@ -55,7 +49,15 @@ class ChatTextArea(TextArea): # type: ignore[misc]
|
|
|
55
49
|
def set_app_reference(self, app: "StrixTUIApp") -> None:
|
|
56
50
|
self._app_reference = app
|
|
57
51
|
|
|
52
|
+
def on_mount(self) -> None:
|
|
53
|
+
self._update_height()
|
|
54
|
+
|
|
58
55
|
def _on_key(self, event: events.Key) -> None:
|
|
56
|
+
if event.key == "shift+enter":
|
|
57
|
+
self.insert("\n")
|
|
58
|
+
event.prevent_default()
|
|
59
|
+
return
|
|
60
|
+
|
|
59
61
|
if event.key == "enter" and self._app_reference:
|
|
60
62
|
text_content = str(self.text) # type: ignore[has-type]
|
|
61
63
|
message = text_content.strip()
|
|
@@ -69,6 +71,20 @@ class ChatTextArea(TextArea): # type: ignore[misc]
|
|
|
69
71
|
|
|
70
72
|
super()._on_key(event)
|
|
71
73
|
|
|
74
|
+
@on(TextArea.Changed) # type: ignore[misc]
|
|
75
|
+
def _update_height(self, _event: TextArea.Changed | None = None) -> None:
|
|
76
|
+
if not self.parent:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
line_count = self.document.line_count
|
|
80
|
+
target_lines = min(max(1, line_count), 8)
|
|
81
|
+
|
|
82
|
+
new_height = target_lines + 2
|
|
83
|
+
|
|
84
|
+
if self.parent.styles.height != new_height:
|
|
85
|
+
self.parent.styles.height = new_height
|
|
86
|
+
self.scroll_cursor_visible()
|
|
87
|
+
|
|
72
88
|
|
|
73
89
|
class SplashScreen(Static): # type: ignore[misc]
|
|
74
90
|
PRIMARY_GREEN = "#22c55e"
|
|
@@ -99,7 +115,7 @@ class SplashScreen(Static): # type: ignore[misc]
|
|
|
99
115
|
yield panel_static
|
|
100
116
|
|
|
101
117
|
def on_mount(self) -> None:
|
|
102
|
-
self._animation_timer = self.set_interval(0.
|
|
118
|
+
self._animation_timer = self.set_interval(0.05, self._animate_start_line)
|
|
103
119
|
|
|
104
120
|
def on_unmount(self) -> None:
|
|
105
121
|
if self._animation_timer is not None:
|
|
@@ -124,10 +140,15 @@ class SplashScreen(Static): # type: ignore[misc]
|
|
|
124
140
|
Align.center(self._build_tagline_text()),
|
|
125
141
|
Align.center(Text(" ")),
|
|
126
142
|
Align.center(start_line.copy()),
|
|
143
|
+
Align.center(Text(" ")),
|
|
144
|
+
Align.center(self._build_url_text()),
|
|
127
145
|
)
|
|
128
146
|
|
|
129
147
|
return Panel.fit(content, border_style=self.PRIMARY_GREEN, padding=(1, 6))
|
|
130
148
|
|
|
149
|
+
def _build_url_text(self) -> Text:
|
|
150
|
+
return Text("strix.ai", style=Style(color=self.PRIMARY_GREEN, bold=True))
|
|
151
|
+
|
|
131
152
|
def _build_welcome_text(self) -> Text:
|
|
132
153
|
text = Text("Welcome to ", style=Style(color="white", bold=True))
|
|
133
154
|
text.append("Strix", style=Style(color=self.PRIMARY_GREEN, bold=True))
|
|
@@ -141,13 +162,25 @@ class SplashScreen(Static): # type: ignore[misc]
|
|
|
141
162
|
return Text("Open-source AI hackers for your apps", style=Style(color="white", dim=True))
|
|
142
163
|
|
|
143
164
|
def _build_start_line_text(self, phase: int) -> Text:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
strix_style = Style(color=self.PRIMARY_GREEN, bold=bool(emphasize))
|
|
165
|
+
full_text = "Starting Strix Agent"
|
|
166
|
+
text_len = len(full_text)
|
|
147
167
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
text
|
|
168
|
+
shine_pos = phase % (text_len + 8)
|
|
169
|
+
|
|
170
|
+
text = Text()
|
|
171
|
+
for i, char in enumerate(full_text):
|
|
172
|
+
dist = abs(i - shine_pos)
|
|
173
|
+
|
|
174
|
+
if dist <= 1:
|
|
175
|
+
style = Style(color="bright_white", bold=True)
|
|
176
|
+
elif dist <= 3:
|
|
177
|
+
style = Style(color="white", bold=True)
|
|
178
|
+
elif dist <= 5:
|
|
179
|
+
style = Style(color="#a3a3a3")
|
|
180
|
+
else:
|
|
181
|
+
style = Style(color="#525252")
|
|
182
|
+
|
|
183
|
+
text.append(char, style=style)
|
|
151
184
|
|
|
152
185
|
return text
|
|
153
186
|
|
|
@@ -217,10 +250,377 @@ class StopAgentScreen(ModalScreen): # type: ignore[misc]
|
|
|
217
250
|
self.app.pop_screen()
|
|
218
251
|
|
|
219
252
|
|
|
253
|
+
class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
|
254
|
+
"""Modal screen to display vulnerability details."""
|
|
255
|
+
|
|
256
|
+
SEVERITY_COLORS: ClassVar[dict[str, str]] = {
|
|
257
|
+
"critical": "#dc2626", # Red
|
|
258
|
+
"high": "#ea580c", # Orange
|
|
259
|
+
"medium": "#d97706", # Amber
|
|
260
|
+
"low": "#22c55e", # Green
|
|
261
|
+
"info": "#3b82f6", # Blue
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
FIELD_STYLE: ClassVar[str] = "bold #4ade80"
|
|
265
|
+
|
|
266
|
+
def __init__(self, vulnerability: dict[str, Any]) -> None:
|
|
267
|
+
super().__init__()
|
|
268
|
+
self.vulnerability = vulnerability
|
|
269
|
+
|
|
270
|
+
def compose(self) -> ComposeResult:
|
|
271
|
+
content = self._render_vulnerability()
|
|
272
|
+
yield Grid(
|
|
273
|
+
VerticalScroll(Static(content, id="vuln_detail_content"), id="vuln_detail_scroll"),
|
|
274
|
+
Horizontal(
|
|
275
|
+
Button("Copy", variant="default", id="copy_vuln_detail"),
|
|
276
|
+
Button("Done", variant="default", id="close_vuln_detail"),
|
|
277
|
+
id="vuln_detail_buttons",
|
|
278
|
+
),
|
|
279
|
+
id="vuln_detail_dialog",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def on_mount(self) -> None:
|
|
283
|
+
close_button = self.query_one("#close_vuln_detail", Button)
|
|
284
|
+
close_button.focus()
|
|
285
|
+
|
|
286
|
+
def _get_cvss_color(self, cvss_score: float) -> str:
|
|
287
|
+
if cvss_score >= 9.0:
|
|
288
|
+
return "#dc2626"
|
|
289
|
+
if cvss_score >= 7.0:
|
|
290
|
+
return "#ea580c"
|
|
291
|
+
if cvss_score >= 4.0:
|
|
292
|
+
return "#d97706"
|
|
293
|
+
if cvss_score >= 0.1:
|
|
294
|
+
return "#65a30d"
|
|
295
|
+
return "#6b7280"
|
|
296
|
+
|
|
297
|
+
def _highlight_python(self, code: str) -> Text:
|
|
298
|
+
try:
|
|
299
|
+
from pygments.lexers import PythonLexer
|
|
300
|
+
from pygments.styles import get_style_by_name
|
|
301
|
+
|
|
302
|
+
lexer = PythonLexer()
|
|
303
|
+
style = get_style_by_name("native")
|
|
304
|
+
colors = {
|
|
305
|
+
token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
text = Text()
|
|
309
|
+
for token_type, token_value in lexer.get_tokens(code):
|
|
310
|
+
if not token_value:
|
|
311
|
+
continue
|
|
312
|
+
color = None
|
|
313
|
+
tt = token_type
|
|
314
|
+
while tt:
|
|
315
|
+
if tt in colors:
|
|
316
|
+
color = colors[tt]
|
|
317
|
+
break
|
|
318
|
+
tt = tt.parent
|
|
319
|
+
text.append(token_value, style=color)
|
|
320
|
+
except (ImportError, KeyError, AttributeError):
|
|
321
|
+
return Text(code)
|
|
322
|
+
else:
|
|
323
|
+
return text
|
|
324
|
+
|
|
325
|
+
def _render_vulnerability(self) -> Text: # noqa: PLR0912, PLR0915
|
|
326
|
+
vuln = self.vulnerability
|
|
327
|
+
text = Text()
|
|
328
|
+
|
|
329
|
+
text.append("🐞 ")
|
|
330
|
+
text.append("Vulnerability Report", style="bold #ea580c")
|
|
331
|
+
|
|
332
|
+
agent_name = vuln.get("agent_name", "")
|
|
333
|
+
if agent_name:
|
|
334
|
+
text.append("\n\n")
|
|
335
|
+
text.append("Agent: ", style=self.FIELD_STYLE)
|
|
336
|
+
text.append(agent_name)
|
|
337
|
+
|
|
338
|
+
title = vuln.get("title", "")
|
|
339
|
+
if title:
|
|
340
|
+
text.append("\n\n")
|
|
341
|
+
text.append("Title: ", style=self.FIELD_STYLE)
|
|
342
|
+
text.append(title)
|
|
343
|
+
|
|
344
|
+
severity = vuln.get("severity", "")
|
|
345
|
+
if severity:
|
|
346
|
+
text.append("\n\n")
|
|
347
|
+
text.append("Severity: ", style=self.FIELD_STYLE)
|
|
348
|
+
severity_color = self.SEVERITY_COLORS.get(severity.lower(), "#6b7280")
|
|
349
|
+
text.append(severity.upper(), style=f"bold {severity_color}")
|
|
350
|
+
|
|
351
|
+
cvss_score = vuln.get("cvss")
|
|
352
|
+
if cvss_score is not None:
|
|
353
|
+
text.append("\n\n")
|
|
354
|
+
text.append("CVSS Score: ", style=self.FIELD_STYLE)
|
|
355
|
+
cvss_color = self._get_cvss_color(float(cvss_score))
|
|
356
|
+
text.append(str(cvss_score), style=f"bold {cvss_color}")
|
|
357
|
+
|
|
358
|
+
target = vuln.get("target", "")
|
|
359
|
+
if target:
|
|
360
|
+
text.append("\n\n")
|
|
361
|
+
text.append("Target: ", style=self.FIELD_STYLE)
|
|
362
|
+
text.append(target)
|
|
363
|
+
|
|
364
|
+
endpoint = vuln.get("endpoint", "")
|
|
365
|
+
if endpoint:
|
|
366
|
+
text.append("\n\n")
|
|
367
|
+
text.append("Endpoint: ", style=self.FIELD_STYLE)
|
|
368
|
+
text.append(endpoint)
|
|
369
|
+
|
|
370
|
+
method = vuln.get("method", "")
|
|
371
|
+
if method:
|
|
372
|
+
text.append("\n\n")
|
|
373
|
+
text.append("Method: ", style=self.FIELD_STYLE)
|
|
374
|
+
text.append(method)
|
|
375
|
+
|
|
376
|
+
cve = vuln.get("cve", "")
|
|
377
|
+
if cve:
|
|
378
|
+
text.append("\n\n")
|
|
379
|
+
text.append("CVE: ", style=self.FIELD_STYLE)
|
|
380
|
+
text.append(cve)
|
|
381
|
+
|
|
382
|
+
# CVSS breakdown
|
|
383
|
+
cvss_breakdown = vuln.get("cvss_breakdown", {})
|
|
384
|
+
if cvss_breakdown:
|
|
385
|
+
cvss_parts = []
|
|
386
|
+
if cvss_breakdown.get("attack_vector"):
|
|
387
|
+
cvss_parts.append(f"AV:{cvss_breakdown['attack_vector']}")
|
|
388
|
+
if cvss_breakdown.get("attack_complexity"):
|
|
389
|
+
cvss_parts.append(f"AC:{cvss_breakdown['attack_complexity']}")
|
|
390
|
+
if cvss_breakdown.get("privileges_required"):
|
|
391
|
+
cvss_parts.append(f"PR:{cvss_breakdown['privileges_required']}")
|
|
392
|
+
if cvss_breakdown.get("user_interaction"):
|
|
393
|
+
cvss_parts.append(f"UI:{cvss_breakdown['user_interaction']}")
|
|
394
|
+
if cvss_breakdown.get("scope"):
|
|
395
|
+
cvss_parts.append(f"S:{cvss_breakdown['scope']}")
|
|
396
|
+
if cvss_breakdown.get("confidentiality"):
|
|
397
|
+
cvss_parts.append(f"C:{cvss_breakdown['confidentiality']}")
|
|
398
|
+
if cvss_breakdown.get("integrity"):
|
|
399
|
+
cvss_parts.append(f"I:{cvss_breakdown['integrity']}")
|
|
400
|
+
if cvss_breakdown.get("availability"):
|
|
401
|
+
cvss_parts.append(f"A:{cvss_breakdown['availability']}")
|
|
402
|
+
if cvss_parts:
|
|
403
|
+
text.append("\n\n")
|
|
404
|
+
text.append("CVSS Vector: ", style=self.FIELD_STYLE)
|
|
405
|
+
text.append("/".join(cvss_parts), style="dim")
|
|
406
|
+
|
|
407
|
+
description = vuln.get("description", "")
|
|
408
|
+
if description:
|
|
409
|
+
text.append("\n\n")
|
|
410
|
+
text.append("Description", style=self.FIELD_STYLE)
|
|
411
|
+
text.append("\n")
|
|
412
|
+
text.append(description)
|
|
413
|
+
|
|
414
|
+
impact = vuln.get("impact", "")
|
|
415
|
+
if impact:
|
|
416
|
+
text.append("\n\n")
|
|
417
|
+
text.append("Impact", style=self.FIELD_STYLE)
|
|
418
|
+
text.append("\n")
|
|
419
|
+
text.append(impact)
|
|
420
|
+
|
|
421
|
+
technical_analysis = vuln.get("technical_analysis", "")
|
|
422
|
+
if technical_analysis:
|
|
423
|
+
text.append("\n\n")
|
|
424
|
+
text.append("Technical Analysis", style=self.FIELD_STYLE)
|
|
425
|
+
text.append("\n")
|
|
426
|
+
text.append(technical_analysis)
|
|
427
|
+
|
|
428
|
+
poc_description = vuln.get("poc_description", "")
|
|
429
|
+
if poc_description:
|
|
430
|
+
text.append("\n\n")
|
|
431
|
+
text.append("PoC Description", style=self.FIELD_STYLE)
|
|
432
|
+
text.append("\n")
|
|
433
|
+
text.append(poc_description)
|
|
434
|
+
|
|
435
|
+
poc_script_code = vuln.get("poc_script_code", "")
|
|
436
|
+
if poc_script_code:
|
|
437
|
+
text.append("\n\n")
|
|
438
|
+
text.append("PoC Code", style=self.FIELD_STYLE)
|
|
439
|
+
text.append("\n")
|
|
440
|
+
text.append_text(self._highlight_python(poc_script_code))
|
|
441
|
+
|
|
442
|
+
remediation_steps = vuln.get("remediation_steps", "")
|
|
443
|
+
if remediation_steps:
|
|
444
|
+
text.append("\n\n")
|
|
445
|
+
text.append("Remediation", style=self.FIELD_STYLE)
|
|
446
|
+
text.append("\n")
|
|
447
|
+
text.append(remediation_steps)
|
|
448
|
+
|
|
449
|
+
return text
|
|
450
|
+
|
|
451
|
+
def _get_markdown_report(self) -> str: # noqa: PLR0912, PLR0915
|
|
452
|
+
"""Get Markdown version of vulnerability report for clipboard."""
|
|
453
|
+
vuln = self.vulnerability
|
|
454
|
+
lines: list[str] = []
|
|
455
|
+
|
|
456
|
+
# Title
|
|
457
|
+
title = vuln.get("title", "Untitled Vulnerability")
|
|
458
|
+
lines.append(f"# {title}")
|
|
459
|
+
lines.append("")
|
|
460
|
+
|
|
461
|
+
# Metadata
|
|
462
|
+
if vuln.get("id"):
|
|
463
|
+
lines.append(f"**ID:** {vuln['id']}")
|
|
464
|
+
if vuln.get("severity"):
|
|
465
|
+
lines.append(f"**Severity:** {vuln['severity'].upper()}")
|
|
466
|
+
if vuln.get("timestamp"):
|
|
467
|
+
lines.append(f"**Found:** {vuln['timestamp']}")
|
|
468
|
+
if vuln.get("agent_name"):
|
|
469
|
+
lines.append(f"**Agent:** {vuln['agent_name']}")
|
|
470
|
+
if vuln.get("target"):
|
|
471
|
+
lines.append(f"**Target:** {vuln['target']}")
|
|
472
|
+
if vuln.get("endpoint"):
|
|
473
|
+
lines.append(f"**Endpoint:** {vuln['endpoint']}")
|
|
474
|
+
if vuln.get("method"):
|
|
475
|
+
lines.append(f"**Method:** {vuln['method']}")
|
|
476
|
+
if vuln.get("cve"):
|
|
477
|
+
lines.append(f"**CVE:** {vuln['cve']}")
|
|
478
|
+
if vuln.get("cvss") is not None:
|
|
479
|
+
lines.append(f"**CVSS:** {vuln['cvss']}")
|
|
480
|
+
|
|
481
|
+
# CVSS Vector
|
|
482
|
+
cvss_breakdown = vuln.get("cvss_breakdown", {})
|
|
483
|
+
if cvss_breakdown:
|
|
484
|
+
abbrevs = {
|
|
485
|
+
"attack_vector": "AV",
|
|
486
|
+
"attack_complexity": "AC",
|
|
487
|
+
"privileges_required": "PR",
|
|
488
|
+
"user_interaction": "UI",
|
|
489
|
+
"scope": "S",
|
|
490
|
+
"confidentiality": "C",
|
|
491
|
+
"integrity": "I",
|
|
492
|
+
"availability": "A",
|
|
493
|
+
}
|
|
494
|
+
parts = [
|
|
495
|
+
f"{abbrevs.get(k, k)}:{v}" for k, v in cvss_breakdown.items() if v and k in abbrevs
|
|
496
|
+
]
|
|
497
|
+
if parts:
|
|
498
|
+
lines.append(f"**CVSS Vector:** {'/'.join(parts)}")
|
|
499
|
+
|
|
500
|
+
# Description
|
|
501
|
+
lines.append("")
|
|
502
|
+
lines.append("## Description")
|
|
503
|
+
lines.append("")
|
|
504
|
+
lines.append(vuln.get("description") or "No description provided.")
|
|
505
|
+
|
|
506
|
+
# Impact
|
|
507
|
+
if vuln.get("impact"):
|
|
508
|
+
lines.extend(["", "## Impact", "", vuln["impact"]])
|
|
509
|
+
|
|
510
|
+
# Technical Analysis
|
|
511
|
+
if vuln.get("technical_analysis"):
|
|
512
|
+
lines.extend(["", "## Technical Analysis", "", vuln["technical_analysis"]])
|
|
513
|
+
|
|
514
|
+
# Proof of Concept
|
|
515
|
+
if vuln.get("poc_description") or vuln.get("poc_script_code"):
|
|
516
|
+
lines.extend(["", "## Proof of Concept", ""])
|
|
517
|
+
if vuln.get("poc_description"):
|
|
518
|
+
lines.append(vuln["poc_description"])
|
|
519
|
+
lines.append("")
|
|
520
|
+
if vuln.get("poc_script_code"):
|
|
521
|
+
lines.append("```python")
|
|
522
|
+
lines.append(vuln["poc_script_code"])
|
|
523
|
+
lines.append("```")
|
|
524
|
+
|
|
525
|
+
# Code Analysis
|
|
526
|
+
if vuln.get("code_file") or vuln.get("code_diff"):
|
|
527
|
+
lines.extend(["", "## Code Analysis", ""])
|
|
528
|
+
if vuln.get("code_file"):
|
|
529
|
+
lines.append(f"**File:** {vuln['code_file']}")
|
|
530
|
+
lines.append("")
|
|
531
|
+
if vuln.get("code_diff"):
|
|
532
|
+
lines.append("**Changes:**")
|
|
533
|
+
lines.append("```diff")
|
|
534
|
+
lines.append(vuln["code_diff"])
|
|
535
|
+
lines.append("```")
|
|
536
|
+
|
|
537
|
+
# Remediation
|
|
538
|
+
if vuln.get("remediation_steps"):
|
|
539
|
+
lines.extend(["", "## Remediation", "", vuln["remediation_steps"]])
|
|
540
|
+
|
|
541
|
+
lines.append("")
|
|
542
|
+
return "\n".join(lines)
|
|
543
|
+
|
|
544
|
+
def on_key(self, event: events.Key) -> None:
|
|
545
|
+
if event.key == "escape":
|
|
546
|
+
self.app.pop_screen()
|
|
547
|
+
event.prevent_default()
|
|
548
|
+
|
|
549
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
550
|
+
if event.button.id == "copy_vuln_detail":
|
|
551
|
+
markdown_text = self._get_markdown_report()
|
|
552
|
+
self.app.copy_to_clipboard(markdown_text)
|
|
553
|
+
|
|
554
|
+
copy_button = self.query_one("#copy_vuln_detail", Button)
|
|
555
|
+
copy_button.label = "Copied!"
|
|
556
|
+
self.set_timer(1.5, lambda: setattr(copy_button, "label", "Copy"))
|
|
557
|
+
elif event.button.id == "close_vuln_detail":
|
|
558
|
+
self.app.pop_screen()
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class VulnerabilityItem(Static): # type: ignore[misc]
|
|
562
|
+
"""A clickable vulnerability item."""
|
|
563
|
+
|
|
564
|
+
def __init__(self, label: Text, vuln_data: dict[str, Any], **kwargs: Any) -> None:
|
|
565
|
+
super().__init__(label, **kwargs)
|
|
566
|
+
self.vuln_data = vuln_data
|
|
567
|
+
|
|
568
|
+
def on_click(self, _event: events.Click) -> None:
|
|
569
|
+
"""Handle click to open vulnerability detail."""
|
|
570
|
+
self.app.push_screen(VulnerabilityDetailScreen(self.vuln_data))
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
class VulnerabilitiesPanel(VerticalScroll): # type: ignore[misc]
|
|
574
|
+
"""A scrollable panel showing found vulnerabilities with severity-colored dots."""
|
|
575
|
+
|
|
576
|
+
SEVERITY_COLORS: ClassVar[dict[str, str]] = {
|
|
577
|
+
"critical": "#dc2626", # Red
|
|
578
|
+
"high": "#ea580c", # Orange
|
|
579
|
+
"medium": "#d97706", # Amber
|
|
580
|
+
"low": "#22c55e", # Green
|
|
581
|
+
"info": "#3b82f6", # Blue
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
585
|
+
super().__init__(*args, **kwargs)
|
|
586
|
+
self._vulnerabilities: list[dict[str, Any]] = []
|
|
587
|
+
|
|
588
|
+
def compose(self) -> ComposeResult:
|
|
589
|
+
return []
|
|
590
|
+
|
|
591
|
+
def update_vulnerabilities(self, vulnerabilities: list[dict[str, Any]]) -> None:
|
|
592
|
+
"""Update the list of vulnerabilities and re-render."""
|
|
593
|
+
if self._vulnerabilities == vulnerabilities:
|
|
594
|
+
return
|
|
595
|
+
self._vulnerabilities = list(vulnerabilities)
|
|
596
|
+
self._render_panel()
|
|
597
|
+
|
|
598
|
+
def _render_panel(self) -> None:
|
|
599
|
+
"""Render the vulnerabilities panel content."""
|
|
600
|
+
for child in list(self.children):
|
|
601
|
+
if isinstance(child, VulnerabilityItem):
|
|
602
|
+
child.remove()
|
|
603
|
+
|
|
604
|
+
if not self._vulnerabilities:
|
|
605
|
+
return
|
|
606
|
+
|
|
607
|
+
for vuln in self._vulnerabilities:
|
|
608
|
+
severity = vuln.get("severity", "info").lower()
|
|
609
|
+
title = vuln.get("title", "Unknown Vulnerability")
|
|
610
|
+
color = self.SEVERITY_COLORS.get(severity, "#3b82f6")
|
|
611
|
+
|
|
612
|
+
label = Text()
|
|
613
|
+
label.append("● ", style=Style(color=color))
|
|
614
|
+
label.append(title, style=Style(color="#d4d4d4"))
|
|
615
|
+
|
|
616
|
+
item = VulnerabilityItem(label, vuln, classes="vuln-item")
|
|
617
|
+
self.mount(item)
|
|
618
|
+
|
|
619
|
+
|
|
220
620
|
class QuitScreen(ModalScreen): # type: ignore[misc]
|
|
221
621
|
def compose(self) -> ComposeResult:
|
|
222
622
|
yield Grid(
|
|
223
|
-
Label("
|
|
623
|
+
Label("Quit Strix?", id="quit_title"),
|
|
224
624
|
Grid(
|
|
225
625
|
Button("Yes", variant="error", id="quit"),
|
|
226
626
|
Button("No", variant="default", id="cancel"),
|
|
@@ -264,6 +664,8 @@ class QuitScreen(ModalScreen): # type: ignore[misc]
|
|
|
264
664
|
class StrixTUIApp(App): # type: ignore[misc]
|
|
265
665
|
CSS_PATH = "assets/tui_styles.tcss"
|
|
266
666
|
|
|
667
|
+
SIDEBAR_MIN_WIDTH = 100
|
|
668
|
+
|
|
267
669
|
selected_agent_id: reactive[str | None] = reactive(default=None)
|
|
268
670
|
show_splash: reactive[bool] = reactive(default=True)
|
|
269
671
|
|
|
@@ -293,19 +695,18 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
293
695
|
self._scan_stop_event = threading.Event()
|
|
294
696
|
self._scan_completed = threading.Event()
|
|
295
697
|
|
|
296
|
-
self.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
"
|
|
300
|
-
"
|
|
301
|
-
"
|
|
302
|
-
"
|
|
303
|
-
"
|
|
304
|
-
"
|
|
698
|
+
self._spinner_frame_index: int = 0 # Current animation frame index
|
|
699
|
+
self._sweep_num_squares: int = 6 # Number of squares in sweep animation
|
|
700
|
+
self._sweep_colors: list[str] = [
|
|
701
|
+
"#000000", # Dimmest (shows dot)
|
|
702
|
+
"#031a09",
|
|
703
|
+
"#052e16",
|
|
704
|
+
"#0d4a2a",
|
|
705
|
+
"#15803d",
|
|
706
|
+
"#22c55e",
|
|
707
|
+
"#4ade80",
|
|
708
|
+
"#86efac", # Brightest
|
|
305
709
|
]
|
|
306
|
-
self._agent_verbs: dict[str, str] = {} # agent_id -> current_verb
|
|
307
|
-
self._agent_verb_timers: dict[str, Any] = {} # agent_id -> timer
|
|
308
|
-
self._agent_dot_states: dict[str, int] = {} # agent_id -> dot_count (0-3)
|
|
309
710
|
self._dot_animation_timer: Any | None = None
|
|
310
711
|
|
|
311
712
|
self._setup_cleanup_handlers()
|
|
@@ -319,7 +720,8 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
319
720
|
}
|
|
320
721
|
|
|
321
722
|
def _build_agent_config(self, args: argparse.Namespace) -> dict[str, Any]:
|
|
322
|
-
|
|
723
|
+
scan_mode = getattr(args, "scan_mode", "deep")
|
|
724
|
+
llm_config = LLMConfig(scan_mode=scan_mode)
|
|
323
725
|
|
|
324
726
|
config = {
|
|
325
727
|
"llm_config": llm_config,
|
|
@@ -396,7 +798,9 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
396
798
|
|
|
397
799
|
stats_display = Static("", id="stats_display")
|
|
398
800
|
|
|
399
|
-
|
|
801
|
+
vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel")
|
|
802
|
+
|
|
803
|
+
sidebar = Vertical(agents_tree, vulnerabilities_panel, stats_display, id="sidebar")
|
|
400
804
|
|
|
401
805
|
content_container.mount(chat_area_container)
|
|
402
806
|
content_container.mount(sidebar)
|
|
@@ -449,7 +853,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
449
853
|
|
|
450
854
|
self._start_scan_thread()
|
|
451
855
|
|
|
452
|
-
self.set_interval(0.
|
|
856
|
+
self.set_interval(0.25, self._update_ui_from_tracer)
|
|
453
857
|
|
|
454
858
|
def _update_ui_from_tracer(self) -> None:
|
|
455
859
|
if self.show_splash:
|
|
@@ -471,7 +875,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
471
875
|
return
|
|
472
876
|
|
|
473
877
|
agent_updates = False
|
|
474
|
-
for agent_id, agent_data in self.tracer.agents.items():
|
|
878
|
+
for agent_id, agent_data in list(self.tracer.agents.items()):
|
|
475
879
|
if agent_id not in self._displayed_agents:
|
|
476
880
|
self._add_agent_node(agent_data)
|
|
477
881
|
self._displayed_agents.add(agent_id)
|
|
@@ -480,7 +884,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
480
884
|
agent_updates = True
|
|
481
885
|
|
|
482
886
|
if agent_updates:
|
|
483
|
-
self.
|
|
887
|
+
self._expand_new_agent_nodes()
|
|
484
888
|
|
|
485
889
|
self._update_chat_view()
|
|
486
890
|
|
|
@@ -488,6 +892,8 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
488
892
|
|
|
489
893
|
self._update_stats_display()
|
|
490
894
|
|
|
895
|
+
self._update_vulnerabilities_panel()
|
|
896
|
+
|
|
491
897
|
def _update_agent_node(self, agent_id: str, agent_data: dict[str, Any]) -> bool:
|
|
492
898
|
if agent_id not in self.agent_nodes:
|
|
493
899
|
return False
|
|
@@ -508,14 +914,9 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
508
914
|
}
|
|
509
915
|
|
|
510
916
|
status_icon = status_indicators.get(status, "🔵")
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
self._start_agent_verb_timer(agent_id)
|
|
515
|
-
elif status == "waiting":
|
|
516
|
-
self._stop_agent_verb_timer(agent_id)
|
|
517
|
-
else:
|
|
518
|
-
self._stop_agent_verb_timer(agent_id)
|
|
917
|
+
vuln_count = self._agent_vulnerability_count(agent_id)
|
|
918
|
+
vuln_indicator = f" ({vuln_count})" if vuln_count > 0 else ""
|
|
919
|
+
agent_name = f"{status_icon} {agent_name_raw}{vuln_indicator}"
|
|
519
920
|
|
|
520
921
|
if agent_node.label != agent_name:
|
|
521
922
|
agent_node.set_label(agent_name)
|
|
@@ -528,11 +929,32 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
528
929
|
|
|
529
930
|
return False
|
|
530
931
|
|
|
531
|
-
def
|
|
532
|
-
|
|
533
|
-
|
|
932
|
+
def _get_chat_content(
|
|
933
|
+
self,
|
|
934
|
+
) -> tuple[Any, str | None]:
|
|
935
|
+
if not self.selected_agent_id:
|
|
936
|
+
return self._get_chat_placeholder_content(
|
|
937
|
+
"Select an agent from the tree to see its activity.", "placeholder-no-agent"
|
|
938
|
+
)
|
|
534
939
|
|
|
535
|
-
|
|
940
|
+
events = self._gather_agent_events(self.selected_agent_id)
|
|
941
|
+
streaming = self.tracer.get_streaming_content(self.selected_agent_id)
|
|
942
|
+
|
|
943
|
+
if not events and not streaming:
|
|
944
|
+
return self._get_chat_placeholder_content(
|
|
945
|
+
"Starting agent...", "placeholder-no-activity"
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
current_event_ids = [e["id"] for e in events]
|
|
949
|
+
|
|
950
|
+
if not streaming and current_event_ids == self._displayed_events:
|
|
951
|
+
return None, None
|
|
952
|
+
|
|
953
|
+
self._displayed_events = current_event_ids
|
|
954
|
+
return self._get_rendered_events_content(events), "chat-content"
|
|
955
|
+
|
|
956
|
+
def _update_chat_view(self) -> None:
|
|
957
|
+
if len(self.screen_stack) > 1 or self.show_splash or not self.is_mounted:
|
|
536
958
|
return
|
|
537
959
|
|
|
538
960
|
try:
|
|
@@ -548,27 +970,12 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
548
970
|
except (AttributeError, ValueError):
|
|
549
971
|
is_at_bottom = True
|
|
550
972
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
)
|
|
555
|
-
else:
|
|
556
|
-
events = self._gather_agent_events(self.selected_agent_id)
|
|
557
|
-
if not events:
|
|
558
|
-
content, css_class = self._get_chat_placeholder_content(
|
|
559
|
-
"Starting agent...", "placeholder-no-activity"
|
|
560
|
-
)
|
|
561
|
-
else:
|
|
562
|
-
current_event_ids = [e["id"] for e in events]
|
|
563
|
-
if current_event_ids == self._displayed_events:
|
|
564
|
-
return
|
|
565
|
-
content = self._get_rendered_events_content(events)
|
|
566
|
-
css_class = "chat-content"
|
|
567
|
-
self._displayed_events = current_event_ids
|
|
973
|
+
content, css_class = self._get_chat_content()
|
|
974
|
+
if content is None:
|
|
975
|
+
return
|
|
568
976
|
|
|
569
977
|
chat_display = self.query_one("#chat_display", Static)
|
|
570
|
-
self.
|
|
571
|
-
|
|
978
|
+
self._safe_widget_operation(chat_display.update, content)
|
|
572
979
|
chat_display.set_classes(css_class)
|
|
573
980
|
|
|
574
981
|
if is_at_bottom:
|
|
@@ -576,26 +983,181 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
576
983
|
|
|
577
984
|
def _get_chat_placeholder_content(
|
|
578
985
|
self, message: str, placeholder_class: str
|
|
579
|
-
) -> tuple[
|
|
986
|
+
) -> tuple[Text, str]:
|
|
580
987
|
self._displayed_events = [placeholder_class]
|
|
581
|
-
|
|
988
|
+
text = Text()
|
|
989
|
+
text.append(message)
|
|
990
|
+
return text, f"chat-placeholder {placeholder_class}"
|
|
991
|
+
|
|
992
|
+
def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> Any:
|
|
993
|
+
renderables: list[Any] = []
|
|
582
994
|
|
|
583
|
-
def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> str:
|
|
584
995
|
if not events:
|
|
585
|
-
return
|
|
996
|
+
return Text()
|
|
586
997
|
|
|
587
|
-
content_lines = []
|
|
588
998
|
for event in events:
|
|
999
|
+
content: Any = None
|
|
1000
|
+
|
|
589
1001
|
if event["type"] == "chat":
|
|
590
|
-
|
|
591
|
-
if chat_content:
|
|
592
|
-
content_lines.append(chat_content)
|
|
1002
|
+
content = self._render_chat_content(event["data"])
|
|
593
1003
|
elif event["type"] == "tool":
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
1004
|
+
content = self._render_tool_content_simple(event["data"])
|
|
1005
|
+
|
|
1006
|
+
if content:
|
|
1007
|
+
if renderables:
|
|
1008
|
+
renderables.append(Text(""))
|
|
1009
|
+
renderables.append(content)
|
|
1010
|
+
|
|
1011
|
+
if self.selected_agent_id:
|
|
1012
|
+
streaming = self.tracer.get_streaming_content(self.selected_agent_id)
|
|
1013
|
+
if streaming:
|
|
1014
|
+
streaming_text = self._render_streaming_content(streaming)
|
|
1015
|
+
if streaming_text:
|
|
1016
|
+
if renderables:
|
|
1017
|
+
renderables.append(Text(""))
|
|
1018
|
+
renderables.append(streaming_text)
|
|
1019
|
+
|
|
1020
|
+
if not renderables:
|
|
1021
|
+
return Text()
|
|
1022
|
+
|
|
1023
|
+
if len(renderables) == 1:
|
|
1024
|
+
return renderables[0]
|
|
1025
|
+
|
|
1026
|
+
return Group(*renderables)
|
|
597
1027
|
|
|
598
|
-
|
|
1028
|
+
def _render_streaming_content(self, content: str) -> Any:
|
|
1029
|
+
from strix.interface.streaming_parser import parse_streaming_content
|
|
1030
|
+
|
|
1031
|
+
renderables: list[Any] = []
|
|
1032
|
+
segments = parse_streaming_content(content)
|
|
1033
|
+
|
|
1034
|
+
for segment in segments:
|
|
1035
|
+
if segment.type == "text":
|
|
1036
|
+
from strix.interface.tool_components.agent_message_renderer import (
|
|
1037
|
+
AgentMessageRenderer,
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
text_content = AgentMessageRenderer.render_simple(segment.content)
|
|
1041
|
+
if renderables:
|
|
1042
|
+
renderables.append(Text(""))
|
|
1043
|
+
renderables.append(text_content)
|
|
1044
|
+
|
|
1045
|
+
elif segment.type == "tool":
|
|
1046
|
+
tool_renderable = self._render_streaming_tool(
|
|
1047
|
+
segment.tool_name or "unknown",
|
|
1048
|
+
segment.args or {},
|
|
1049
|
+
segment.is_complete,
|
|
1050
|
+
)
|
|
1051
|
+
if renderables:
|
|
1052
|
+
renderables.append(Text(""))
|
|
1053
|
+
renderables.append(tool_renderable)
|
|
1054
|
+
|
|
1055
|
+
if not renderables:
|
|
1056
|
+
return Text()
|
|
1057
|
+
|
|
1058
|
+
if len(renderables) == 1:
|
|
1059
|
+
return renderables[0]
|
|
1060
|
+
|
|
1061
|
+
return Group(*renderables)
|
|
1062
|
+
|
|
1063
|
+
def _render_streaming_tool(
|
|
1064
|
+
self, tool_name: str, args: dict[str, str], is_complete: bool
|
|
1065
|
+
) -> Any:
|
|
1066
|
+
from strix.interface.tool_components.registry import get_tool_renderer
|
|
1067
|
+
|
|
1068
|
+
tool_data = {
|
|
1069
|
+
"tool_name": tool_name,
|
|
1070
|
+
"args": args,
|
|
1071
|
+
"status": "completed" if is_complete else "running",
|
|
1072
|
+
"result": None,
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
renderer = get_tool_renderer(tool_name)
|
|
1076
|
+
if renderer:
|
|
1077
|
+
widget = renderer.render(tool_data)
|
|
1078
|
+
return widget.renderable
|
|
1079
|
+
|
|
1080
|
+
return self._render_default_streaming_tool(tool_name, args, is_complete)
|
|
1081
|
+
|
|
1082
|
+
def _render_default_streaming_tool(
|
|
1083
|
+
self, tool_name: str, args: dict[str, str], is_complete: bool
|
|
1084
|
+
) -> Text:
|
|
1085
|
+
text = Text()
|
|
1086
|
+
|
|
1087
|
+
if is_complete:
|
|
1088
|
+
text.append("✓ ", style="green")
|
|
1089
|
+
else:
|
|
1090
|
+
text.append("● ", style="yellow")
|
|
1091
|
+
|
|
1092
|
+
text.append("Using tool ", style="dim")
|
|
1093
|
+
text.append(tool_name, style="bold blue")
|
|
1094
|
+
|
|
1095
|
+
if args:
|
|
1096
|
+
for key, value in list(args.items())[:3]:
|
|
1097
|
+
text.append("\n ")
|
|
1098
|
+
text.append(key, style="dim")
|
|
1099
|
+
text.append(": ")
|
|
1100
|
+
display_value = value if len(value) <= 100 else value[:97] + "..."
|
|
1101
|
+
text.append(display_value, style="italic" if not is_complete else None)
|
|
1102
|
+
|
|
1103
|
+
return text
|
|
1104
|
+
|
|
1105
|
+
def _get_status_display_content(
|
|
1106
|
+
self, agent_id: str, agent_data: dict[str, Any]
|
|
1107
|
+
) -> tuple[Text | None, Text, bool]:
|
|
1108
|
+
status = agent_data.get("status", "running")
|
|
1109
|
+
|
|
1110
|
+
def keymap_styled(keys: list[tuple[str, str]]) -> Text:
|
|
1111
|
+
t = Text()
|
|
1112
|
+
for i, (key, action) in enumerate(keys):
|
|
1113
|
+
if i > 0:
|
|
1114
|
+
t.append(" · ", style="dim")
|
|
1115
|
+
t.append(key, style="white")
|
|
1116
|
+
t.append(" ", style="dim")
|
|
1117
|
+
t.append(action, style="dim")
|
|
1118
|
+
return t
|
|
1119
|
+
|
|
1120
|
+
simple_statuses: dict[str, tuple[str, str]] = {
|
|
1121
|
+
"stopping": ("Agent stopping...", ""),
|
|
1122
|
+
"stopped": ("Agent stopped", ""),
|
|
1123
|
+
"completed": ("Agent completed", ""),
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if status in simple_statuses:
|
|
1127
|
+
msg, _ = simple_statuses[status]
|
|
1128
|
+
text = Text()
|
|
1129
|
+
text.append(msg)
|
|
1130
|
+
return (text, Text(), False)
|
|
1131
|
+
|
|
1132
|
+
if status == "llm_failed":
|
|
1133
|
+
error_msg = agent_data.get("error_message", "")
|
|
1134
|
+
text = Text()
|
|
1135
|
+
if error_msg:
|
|
1136
|
+
text.append(error_msg, style="red")
|
|
1137
|
+
else:
|
|
1138
|
+
text.append("LLM request failed", style="red")
|
|
1139
|
+
self._stop_dot_animation()
|
|
1140
|
+
keymap = Text()
|
|
1141
|
+
keymap.append("Send message to retry", style="dim")
|
|
1142
|
+
return (text, keymap, False)
|
|
1143
|
+
|
|
1144
|
+
if status == "waiting":
|
|
1145
|
+
keymap = Text()
|
|
1146
|
+
keymap.append("Send message to resume", style="dim")
|
|
1147
|
+
return (Text(" "), keymap, False)
|
|
1148
|
+
|
|
1149
|
+
if status == "running":
|
|
1150
|
+
if self._agent_has_real_activity(agent_id):
|
|
1151
|
+
animated_text = Text()
|
|
1152
|
+
animated_text.append_text(self._get_sweep_animation(self._sweep_colors))
|
|
1153
|
+
animated_text.append("esc", style="white")
|
|
1154
|
+
animated_text.append(" ", style="dim")
|
|
1155
|
+
animated_text.append("stop", style="dim")
|
|
1156
|
+
return (animated_text, keymap_styled([("ctrl-q", "quit")]), True)
|
|
1157
|
+
animated_text = self._get_animated_verb_text(agent_id, "Initializing")
|
|
1158
|
+
return (animated_text, keymap_styled([("ctrl-q", "quit")]), True)
|
|
1159
|
+
|
|
1160
|
+
return (None, Text(), False)
|
|
599
1161
|
|
|
600
1162
|
def _update_agent_status_display(self) -> None:
|
|
601
1163
|
try:
|
|
@@ -615,52 +1177,20 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
615
1177
|
|
|
616
1178
|
try:
|
|
617
1179
|
agent_data = self.tracer.agents[self.selected_agent_id]
|
|
618
|
-
|
|
1180
|
+
content, keymap, should_animate = self._get_status_display_content(
|
|
1181
|
+
self.selected_agent_id, agent_data
|
|
1182
|
+
)
|
|
619
1183
|
|
|
620
|
-
if
|
|
621
|
-
self._safe_widget_operation(status_text.update, "Agent stopping...")
|
|
622
|
-
self._safe_widget_operation(keymap_indicator.update, "")
|
|
623
|
-
self._safe_widget_operation(status_display.remove_class, "hidden")
|
|
624
|
-
elif status == "stopped":
|
|
625
|
-
self._safe_widget_operation(status_text.update, "Agent stopped")
|
|
626
|
-
self._safe_widget_operation(keymap_indicator.update, "")
|
|
627
|
-
self._safe_widget_operation(status_display.remove_class, "hidden")
|
|
628
|
-
elif status == "completed":
|
|
629
|
-
self._safe_widget_operation(status_text.update, "Agent completed")
|
|
630
|
-
self._safe_widget_operation(keymap_indicator.update, "")
|
|
631
|
-
self._safe_widget_operation(status_display.remove_class, "hidden")
|
|
632
|
-
elif status == "llm_failed":
|
|
633
|
-
error_msg = agent_data.get("error_message", "")
|
|
634
|
-
display_msg = (
|
|
635
|
-
f"[red]{escape_markup(error_msg)}[/red]"
|
|
636
|
-
if error_msg
|
|
637
|
-
else "[red]LLM request failed[/red]"
|
|
638
|
-
)
|
|
639
|
-
self._safe_widget_operation(status_text.update, display_msg)
|
|
640
|
-
self._safe_widget_operation(
|
|
641
|
-
keymap_indicator.update, "[dim]Send message to retry[/dim]"
|
|
642
|
-
)
|
|
643
|
-
self._safe_widget_operation(status_display.remove_class, "hidden")
|
|
644
|
-
self._stop_dot_animation()
|
|
645
|
-
elif status == "waiting":
|
|
646
|
-
animated_text = self._get_animated_waiting_text(self.selected_agent_id)
|
|
647
|
-
self._safe_widget_operation(status_text.update, animated_text)
|
|
648
|
-
self._safe_widget_operation(
|
|
649
|
-
keymap_indicator.update, "[dim]Send message to resume[/dim]"
|
|
650
|
-
)
|
|
651
|
-
self._safe_widget_operation(status_display.remove_class, "hidden")
|
|
652
|
-
self._start_dot_animation()
|
|
653
|
-
elif status == "running":
|
|
654
|
-
current_verb = self._get_agent_verb(self.selected_agent_id)
|
|
655
|
-
animated_text = self._get_animated_verb_text(self.selected_agent_id, current_verb)
|
|
656
|
-
self._safe_widget_operation(status_text.update, animated_text)
|
|
657
|
-
self._safe_widget_operation(
|
|
658
|
-
keymap_indicator.update, "[dim]ESC to stop | CTRL-C to quit and save[/dim]"
|
|
659
|
-
)
|
|
660
|
-
self._safe_widget_operation(status_display.remove_class, "hidden")
|
|
661
|
-
self._start_dot_animation()
|
|
662
|
-
else:
|
|
1184
|
+
if not content:
|
|
663
1185
|
self._safe_widget_operation(status_display.add_class, "hidden")
|
|
1186
|
+
return
|
|
1187
|
+
|
|
1188
|
+
self._safe_widget_operation(status_text.update, content)
|
|
1189
|
+
self._safe_widget_operation(keymap_indicator.update, keymap)
|
|
1190
|
+
self._safe_widget_operation(status_display.remove_class, "hidden")
|
|
1191
|
+
|
|
1192
|
+
if should_animate:
|
|
1193
|
+
self._start_dot_animation()
|
|
664
1194
|
|
|
665
1195
|
except (KeyError, Exception):
|
|
666
1196
|
self._safe_widget_operation(status_display.add_class, "hidden")
|
|
@@ -676,7 +1206,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
676
1206
|
|
|
677
1207
|
stats_content = Text()
|
|
678
1208
|
|
|
679
|
-
stats_text =
|
|
1209
|
+
stats_text = build_tui_stats_text(self.tracer, self.agent_config)
|
|
680
1210
|
if stats_text:
|
|
681
1211
|
stats_content.append(stats_text)
|
|
682
1212
|
|
|
@@ -684,62 +1214,95 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
684
1214
|
|
|
685
1215
|
stats_panel = Panel(
|
|
686
1216
|
stats_content,
|
|
687
|
-
|
|
688
|
-
title_align="left",
|
|
689
|
-
border_style="#22c55e",
|
|
1217
|
+
border_style="#333333",
|
|
690
1218
|
padding=(0, 1),
|
|
691
1219
|
)
|
|
692
1220
|
|
|
693
1221
|
self._safe_widget_operation(stats_display.update, stats_panel)
|
|
694
1222
|
|
|
695
|
-
def
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
def _start_agent_verb_timer(self, agent_id: str) -> None:
|
|
701
|
-
if agent_id not in self._agent_verb_timers:
|
|
702
|
-
self._agent_verb_timers[agent_id] = self.set_interval(
|
|
703
|
-
30.0, lambda: self._change_agent_action_verb(agent_id)
|
|
704
|
-
)
|
|
705
|
-
|
|
706
|
-
def _stop_agent_verb_timer(self, agent_id: str) -> None:
|
|
707
|
-
if agent_id in self._agent_verb_timers:
|
|
708
|
-
self._agent_verb_timers[agent_id].stop()
|
|
709
|
-
del self._agent_verb_timers[agent_id]
|
|
710
|
-
|
|
711
|
-
def _change_agent_action_verb(self, agent_id: str) -> None:
|
|
712
|
-
if agent_id not in self._agent_verbs:
|
|
713
|
-
self._agent_verbs[agent_id] = random.choice(self._action_verbs) # nosec B311 # noqa: S311
|
|
1223
|
+
def _update_vulnerabilities_panel(self) -> None:
|
|
1224
|
+
"""Update the vulnerabilities panel with current vulnerability data."""
|
|
1225
|
+
try:
|
|
1226
|
+
vuln_panel = self.query_one("#vulnerabilities_panel", VulnerabilitiesPanel)
|
|
1227
|
+
except (ValueError, Exception):
|
|
714
1228
|
return
|
|
715
1229
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
self._agent_verbs[agent_id] = random.choice(available_verbs) # nosec B311 # noqa: S311
|
|
719
|
-
|
|
720
|
-
if self.selected_agent_id == agent_id:
|
|
721
|
-
self._update_agent_status_display()
|
|
1230
|
+
if not self._is_widget_safe(vuln_panel):
|
|
1231
|
+
return
|
|
722
1232
|
|
|
723
|
-
|
|
724
|
-
if agent_id not in self._agent_dot_states:
|
|
725
|
-
self._agent_dot_states[agent_id] = 0
|
|
1233
|
+
vulnerabilities = self.tracer.vulnerability_reports
|
|
726
1234
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1235
|
+
if not vulnerabilities:
|
|
1236
|
+
self._safe_widget_operation(vuln_panel.add_class, "hidden")
|
|
1237
|
+
return
|
|
730
1238
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1239
|
+
enriched_vulns = []
|
|
1240
|
+
for vuln in vulnerabilities:
|
|
1241
|
+
enriched = dict(vuln)
|
|
1242
|
+
report_id = vuln.get("id", "")
|
|
1243
|
+
agent_name = self._get_agent_name_for_vulnerability(report_id)
|
|
1244
|
+
if agent_name:
|
|
1245
|
+
enriched["agent_name"] = agent_name
|
|
1246
|
+
enriched_vulns.append(enriched)
|
|
1247
|
+
|
|
1248
|
+
self._safe_widget_operation(vuln_panel.remove_class, "hidden")
|
|
1249
|
+
vuln_panel.update_vulnerabilities(enriched_vulns)
|
|
1250
|
+
|
|
1251
|
+
def _get_agent_name_for_vulnerability(self, report_id: str) -> str | None:
|
|
1252
|
+
"""Find the agent name that created a vulnerability report."""
|
|
1253
|
+
for _exec_id, tool_data in list(self.tracer.tool_executions.items()):
|
|
1254
|
+
if tool_data.get("tool_name") == "create_vulnerability_report":
|
|
1255
|
+
result = tool_data.get("result", {})
|
|
1256
|
+
if isinstance(result, dict) and result.get("report_id") == report_id:
|
|
1257
|
+
agent_id = tool_data.get("agent_id")
|
|
1258
|
+
if agent_id and agent_id in self.tracer.agents:
|
|
1259
|
+
name: str = self.tracer.agents[agent_id].get("name", "Unknown Agent")
|
|
1260
|
+
return name
|
|
1261
|
+
return None
|
|
1262
|
+
|
|
1263
|
+
def _get_sweep_animation(self, color_palette: list[str]) -> Text:
|
|
1264
|
+
text = Text()
|
|
1265
|
+
num_squares = self._sweep_num_squares
|
|
1266
|
+
num_colors = len(color_palette)
|
|
1267
|
+
|
|
1268
|
+
offset = num_colors - 1
|
|
1269
|
+
max_pos = (num_squares - 1) + offset
|
|
1270
|
+
total_range = max_pos + offset
|
|
1271
|
+
cycle_length = total_range * 2
|
|
1272
|
+
frame_in_cycle = self._spinner_frame_index % cycle_length
|
|
1273
|
+
|
|
1274
|
+
wave_pos = total_range - abs(total_range - frame_in_cycle)
|
|
1275
|
+
sweep_pos = wave_pos - offset
|
|
1276
|
+
|
|
1277
|
+
dot_color = "#0a3d1f"
|
|
1278
|
+
|
|
1279
|
+
for i in range(num_squares):
|
|
1280
|
+
dist = abs(i - sweep_pos)
|
|
1281
|
+
color_idx = max(0, num_colors - 1 - dist)
|
|
1282
|
+
|
|
1283
|
+
if color_idx == 0:
|
|
1284
|
+
text.append("·", style=Style(color=dot_color))
|
|
1285
|
+
else:
|
|
1286
|
+
color = color_palette[color_idx]
|
|
1287
|
+
text.append("▪", style=Style(color=color))
|
|
734
1288
|
|
|
735
|
-
|
|
736
|
-
|
|
1289
|
+
text.append(" ")
|
|
1290
|
+
return text
|
|
737
1291
|
|
|
738
|
-
|
|
1292
|
+
def _get_animated_verb_text(self, agent_id: str, verb: str) -> Text: # noqa: ARG002
|
|
1293
|
+
text = Text()
|
|
1294
|
+
sweep = self._get_sweep_animation(self._sweep_colors)
|
|
1295
|
+
text.append_text(sweep)
|
|
1296
|
+
parts = verb.split(" ", 1)
|
|
1297
|
+
text.append(parts[0], style="white")
|
|
1298
|
+
if len(parts) > 1:
|
|
1299
|
+
text.append(" ", style="dim")
|
|
1300
|
+
text.append(parts[1], style="dim")
|
|
1301
|
+
return text
|
|
739
1302
|
|
|
740
1303
|
def _start_dot_animation(self) -> None:
|
|
741
1304
|
if self._dot_animation_timer is None:
|
|
742
|
-
self._dot_animation_timer = self.set_interval(0.
|
|
1305
|
+
self._dot_animation_timer = self.set_interval(0.06, self._animate_dots)
|
|
743
1306
|
|
|
744
1307
|
def _stop_dot_animation(self) -> None:
|
|
745
1308
|
if self._dot_animation_timer is not None:
|
|
@@ -749,29 +1312,53 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
749
1312
|
def _animate_dots(self) -> None:
|
|
750
1313
|
has_active_agents = False
|
|
751
1314
|
|
|
752
|
-
|
|
1315
|
+
if self.selected_agent_id and self.selected_agent_id in self.tracer.agents:
|
|
1316
|
+
agent_data = self.tracer.agents[self.selected_agent_id]
|
|
753
1317
|
status = agent_data.get("status", "running")
|
|
754
1318
|
if status in ["running", "waiting"]:
|
|
755
1319
|
has_active_agents = True
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
and self.selected_agent_id in self.tracer.agents
|
|
763
|
-
):
|
|
764
|
-
selected_status = self.tracer.agents[self.selected_agent_id].get("status", "running")
|
|
765
|
-
if selected_status in ["running", "waiting"]:
|
|
1320
|
+
num_colors = len(self._sweep_colors)
|
|
1321
|
+
offset = num_colors - 1
|
|
1322
|
+
max_pos = (self._sweep_num_squares - 1) + offset
|
|
1323
|
+
total_range = max_pos + offset
|
|
1324
|
+
cycle_length = total_range * 2
|
|
1325
|
+
self._spinner_frame_index = (self._spinner_frame_index + 1) % cycle_length
|
|
766
1326
|
self._update_agent_status_display()
|
|
767
1327
|
|
|
1328
|
+
if not has_active_agents:
|
|
1329
|
+
has_active_agents = any(
|
|
1330
|
+
agent_data.get("status", "running") in ["running", "waiting"]
|
|
1331
|
+
for agent_data in self.tracer.agents.values()
|
|
1332
|
+
)
|
|
1333
|
+
|
|
768
1334
|
if not has_active_agents:
|
|
769
1335
|
self._stop_dot_animation()
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1336
|
+
self._spinner_frame_index = 0
|
|
1337
|
+
|
|
1338
|
+
def _agent_has_real_activity(self, agent_id: str) -> bool:
|
|
1339
|
+
initial_tools = {"scan_start_info", "subagent_start_info"}
|
|
1340
|
+
|
|
1341
|
+
for _exec_id, tool_data in list(self.tracer.tool_executions.items()):
|
|
1342
|
+
if tool_data.get("agent_id") == agent_id:
|
|
1343
|
+
tool_name = tool_data.get("tool_name", "")
|
|
1344
|
+
if tool_name not in initial_tools:
|
|
1345
|
+
return True
|
|
1346
|
+
|
|
1347
|
+
streaming = self.tracer.get_streaming_content(agent_id)
|
|
1348
|
+
return bool(streaming and streaming.strip())
|
|
1349
|
+
|
|
1350
|
+
def _agent_vulnerability_count(self, agent_id: str) -> int:
|
|
1351
|
+
count = 0
|
|
1352
|
+
for _exec_id, tool_data in list(self.tracer.tool_executions.items()):
|
|
1353
|
+
if tool_data.get("agent_id") == agent_id:
|
|
1354
|
+
tool_name = tool_data.get("tool_name", "")
|
|
1355
|
+
if tool_name == "create_vulnerability_report":
|
|
1356
|
+
status = tool_data.get("status", "")
|
|
1357
|
+
if status == "completed":
|
|
1358
|
+
result = tool_data.get("result", {})
|
|
1359
|
+
if isinstance(result, dict) and result.get("success"):
|
|
1360
|
+
count += 1
|
|
1361
|
+
return count
|
|
775
1362
|
|
|
776
1363
|
def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]:
|
|
777
1364
|
chat_events = [
|
|
@@ -792,7 +1379,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
792
1379
|
"id": f"tool_{exec_id}",
|
|
793
1380
|
"data": tool_data,
|
|
794
1381
|
}
|
|
795
|
-
for exec_id, tool_data in self.tracer.tool_executions.items()
|
|
1382
|
+
for exec_id, tool_data in list(self.tracer.tool_executions.items())
|
|
796
1383
|
if tool_data.get("agent_id") == agent_id
|
|
797
1384
|
]
|
|
798
1385
|
|
|
@@ -871,10 +1458,9 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
871
1458
|
}
|
|
872
1459
|
|
|
873
1460
|
status_icon = status_indicators.get(status, "🔵")
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
self._start_agent_verb_timer(agent_id)
|
|
1461
|
+
vuln_count = self._agent_vulnerability_count(agent_id)
|
|
1462
|
+
vuln_indicator = f" ({vuln_count})" if vuln_count > 0 else ""
|
|
1463
|
+
agent_name = f"{status_icon} {agent_name_raw}{vuln_indicator}"
|
|
878
1464
|
|
|
879
1465
|
try:
|
|
880
1466
|
if parent_id and parent_id in self.agent_nodes:
|
|
@@ -904,6 +1490,13 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
904
1490
|
|
|
905
1491
|
logging.warning(f"Failed to add agent node {agent_id}: {e}")
|
|
906
1492
|
|
|
1493
|
+
def _expand_new_agent_nodes(self) -> None:
|
|
1494
|
+
if len(self.screen_stack) > 1 or self.show_splash:
|
|
1495
|
+
return
|
|
1496
|
+
|
|
1497
|
+
if not self.is_mounted:
|
|
1498
|
+
return
|
|
1499
|
+
|
|
907
1500
|
def _expand_all_agent_nodes(self) -> None:
|
|
908
1501
|
if len(self.screen_stack) > 1 or self.show_splash:
|
|
909
1502
|
return
|
|
@@ -939,7 +1532,9 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
939
1532
|
}
|
|
940
1533
|
|
|
941
1534
|
status_icon = status_indicators.get(status, "🔵")
|
|
942
|
-
|
|
1535
|
+
vuln_count = self._agent_vulnerability_count(agent_id)
|
|
1536
|
+
vuln_indicator = f" ({vuln_count})" if vuln_count > 0 else ""
|
|
1537
|
+
agent_name = f"{status_icon} {agent_name_raw}{vuln_indicator}"
|
|
943
1538
|
|
|
944
1539
|
new_node = new_parent.add(
|
|
945
1540
|
agent_name,
|
|
@@ -958,7 +1553,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
958
1553
|
def _reorganize_orphaned_agents(self, new_parent_id: str) -> None:
|
|
959
1554
|
agents_to_move = []
|
|
960
1555
|
|
|
961
|
-
for agent_id, agent_data in self.tracer.agents.items():
|
|
1556
|
+
for agent_id, agent_data in list(self.tracer.agents.items()):
|
|
962
1557
|
if (
|
|
963
1558
|
agent_data.get("parent_id") == new_parent_id
|
|
964
1559
|
and agent_id in self.agent_nodes
|
|
@@ -983,90 +1578,99 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
983
1578
|
old_node.remove()
|
|
984
1579
|
|
|
985
1580
|
parent_node.allow_expand = True
|
|
986
|
-
|
|
1581
|
+
parent_node.expand()
|
|
987
1582
|
|
|
988
|
-
def _render_chat_content(self, msg_data: dict[str, Any]) ->
|
|
1583
|
+
def _render_chat_content(self, msg_data: dict[str, Any]) -> Any:
|
|
989
1584
|
role = msg_data.get("role")
|
|
990
|
-
content =
|
|
1585
|
+
content = msg_data.get("content", "")
|
|
1586
|
+
metadata = msg_data.get("metadata", {})
|
|
991
1587
|
|
|
992
1588
|
if not content:
|
|
993
|
-
return
|
|
1589
|
+
return None
|
|
994
1590
|
|
|
995
1591
|
if role == "user":
|
|
996
1592
|
from strix.interface.tool_components.user_message_renderer import UserMessageRenderer
|
|
997
1593
|
|
|
998
1594
|
return UserMessageRenderer.render_simple(content)
|
|
999
|
-
return content
|
|
1000
1595
|
|
|
1001
|
-
|
|
1596
|
+
if metadata.get("interrupted"):
|
|
1597
|
+
streaming_result = self._render_streaming_content(content)
|
|
1598
|
+
interrupted_text = Text()
|
|
1599
|
+
interrupted_text.append("\n")
|
|
1600
|
+
interrupted_text.append("⚠ ", style="yellow")
|
|
1601
|
+
interrupted_text.append("Interrupted by user", style="yellow dim")
|
|
1602
|
+
return Group(streaming_result, interrupted_text)
|
|
1603
|
+
|
|
1604
|
+
from strix.interface.tool_components.agent_message_renderer import AgentMessageRenderer
|
|
1605
|
+
|
|
1606
|
+
return AgentMessageRenderer.render_simple(content)
|
|
1607
|
+
|
|
1608
|
+
def _render_tool_content_simple(self, tool_data: dict[str, Any]) -> Any:
|
|
1002
1609
|
tool_name = tool_data.get("tool_name", "Unknown Tool")
|
|
1003
1610
|
args = tool_data.get("args", {})
|
|
1004
1611
|
status = tool_data.get("status", "unknown")
|
|
1005
1612
|
result = tool_data.get("result")
|
|
1006
1613
|
|
|
1007
|
-
tool_colors = {
|
|
1008
|
-
"terminal_execute": "#22c55e",
|
|
1009
|
-
"browser_action": "#06b6d4",
|
|
1010
|
-
"python_action": "#3b82f6",
|
|
1011
|
-
"agents_graph_action": "#fbbf24",
|
|
1012
|
-
"file_edit_action": "#10b981",
|
|
1013
|
-
"proxy_action": "#06b6d4",
|
|
1014
|
-
"notes_action": "#fbbf24",
|
|
1015
|
-
"thinking_action": "#a855f7",
|
|
1016
|
-
"web_search_action": "#22c55e",
|
|
1017
|
-
"finish_action": "#dc2626",
|
|
1018
|
-
"reporting_action": "#ea580c",
|
|
1019
|
-
"scan_start_info": "#22c55e",
|
|
1020
|
-
"subagent_start_info": "#22c55e",
|
|
1021
|
-
"llm_error_details": "#dc2626",
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
color = tool_colors.get(tool_name, "#737373")
|
|
1025
|
-
|
|
1026
1614
|
from strix.interface.tool_components.registry import get_tool_renderer
|
|
1027
1615
|
|
|
1028
1616
|
renderer = get_tool_renderer(tool_name)
|
|
1029
1617
|
|
|
1030
1618
|
if renderer:
|
|
1031
1619
|
widget = renderer.render(tool_data)
|
|
1032
|
-
|
|
1033
|
-
elif tool_name == "llm_error_details":
|
|
1034
|
-
lines = ["[red]✗ LLM Request Failed[/red]"]
|
|
1035
|
-
if args.get("details"):
|
|
1036
|
-
details = args["details"]
|
|
1037
|
-
if len(details) > 300:
|
|
1038
|
-
details = details[:297] + "..."
|
|
1039
|
-
lines.append(f"[dim]Details:[/dim] {escape_markup(details)}")
|
|
1040
|
-
content = "\n".join(lines)
|
|
1041
|
-
else:
|
|
1042
|
-
status_icons = {
|
|
1043
|
-
"running": "[yellow]●[/yellow]",
|
|
1044
|
-
"completed": "[green]✓[/green]",
|
|
1045
|
-
"failed": "[red]✗[/red]",
|
|
1046
|
-
"error": "[red]✗[/red]",
|
|
1047
|
-
}
|
|
1048
|
-
status_icon = status_icons.get(status, "[dim]○[/dim]")
|
|
1620
|
+
return widget.renderable
|
|
1049
1621
|
|
|
1050
|
-
|
|
1622
|
+
text = Text()
|
|
1051
1623
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
str_v = str(v)
|
|
1055
|
-
if len(str_v) > 80:
|
|
1056
|
-
str_v = str_v[:77] + "..."
|
|
1057
|
-
lines.append(f" [dim]{k}:[/] {escape_markup(str_v)}")
|
|
1624
|
+
if tool_name in ("llm_error_details", "sandbox_error_details"):
|
|
1625
|
+
return self._render_error_details(text, tool_name, args)
|
|
1058
1626
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
if len(result_str) > 150:
|
|
1062
|
-
result_str = result_str[:147] + "..."
|
|
1063
|
-
lines.append(f"[bold]Result:[/] {escape_markup(result_str)}")
|
|
1627
|
+
text.append("→ Using tool ")
|
|
1628
|
+
text.append(tool_name, style="bold blue")
|
|
1064
1629
|
|
|
1065
|
-
|
|
1630
|
+
status_styles = {
|
|
1631
|
+
"running": ("●", "yellow"),
|
|
1632
|
+
"completed": ("✓", "green"),
|
|
1633
|
+
"failed": ("✗", "red"),
|
|
1634
|
+
"error": ("✗", "red"),
|
|
1635
|
+
}
|
|
1636
|
+
icon, style = status_styles.get(status, ("○", "dim"))
|
|
1637
|
+
text.append(" ")
|
|
1638
|
+
text.append(icon, style=style)
|
|
1639
|
+
|
|
1640
|
+
if args:
|
|
1641
|
+
for k, v in list(args.items())[:5]:
|
|
1642
|
+
str_v = str(v)
|
|
1643
|
+
if len(str_v) > 500:
|
|
1644
|
+
str_v = str_v[:497] + "..."
|
|
1645
|
+
text.append("\n ")
|
|
1646
|
+
text.append(k, style="dim")
|
|
1647
|
+
text.append(": ")
|
|
1648
|
+
text.append(str_v)
|
|
1649
|
+
|
|
1650
|
+
if status in ["completed", "failed", "error"] and result:
|
|
1651
|
+
result_str = str(result)
|
|
1652
|
+
if len(result_str) > 1000:
|
|
1653
|
+
result_str = result_str[:997] + "..."
|
|
1654
|
+
text.append("\n")
|
|
1655
|
+
text.append("Result: ", style="bold")
|
|
1656
|
+
text.append(result_str)
|
|
1066
1657
|
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1658
|
+
return text
|
|
1659
|
+
|
|
1660
|
+
def _render_error_details(self, text: Any, tool_name: str, args: dict[str, Any]) -> Any:
|
|
1661
|
+
if tool_name == "llm_error_details":
|
|
1662
|
+
text.append("✗ LLM Request Failed", style="red")
|
|
1663
|
+
else:
|
|
1664
|
+
text.append("✗ Sandbox Initialization Failed", style="red")
|
|
1665
|
+
if args.get("error"):
|
|
1666
|
+
text.append(f"\n{args['error']}", style="bold red")
|
|
1667
|
+
if args.get("details"):
|
|
1668
|
+
details = str(args["details"])
|
|
1669
|
+
if len(details) > 1000:
|
|
1670
|
+
details = details[:997] + "..."
|
|
1671
|
+
text.append("\nDetails: ", style="dim")
|
|
1672
|
+
text.append(details)
|
|
1673
|
+
return text
|
|
1070
1674
|
|
|
1071
1675
|
@on(Tree.NodeHighlighted) # type: ignore[misc]
|
|
1072
1676
|
def handle_tree_highlight(self, event: Tree.NodeHighlighted) -> None:
|
|
@@ -1088,10 +1692,48 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
1088
1692
|
if agent_id:
|
|
1089
1693
|
self.selected_agent_id = agent_id
|
|
1090
1694
|
|
|
1695
|
+
@on(Tree.NodeSelected) # type: ignore[misc]
|
|
1696
|
+
def handle_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
|
1697
|
+
if len(self.screen_stack) > 1 or self.show_splash:
|
|
1698
|
+
return
|
|
1699
|
+
|
|
1700
|
+
if not self.is_mounted:
|
|
1701
|
+
return
|
|
1702
|
+
|
|
1703
|
+
node = event.node
|
|
1704
|
+
|
|
1705
|
+
if node.allow_expand:
|
|
1706
|
+
if node.is_expanded:
|
|
1707
|
+
node.collapse()
|
|
1708
|
+
else:
|
|
1709
|
+
node.expand()
|
|
1710
|
+
|
|
1091
1711
|
def _send_user_message(self, message: str) -> None:
|
|
1092
1712
|
if not self.selected_agent_id:
|
|
1093
1713
|
return
|
|
1094
1714
|
|
|
1715
|
+
if self.tracer:
|
|
1716
|
+
streaming_content = self.tracer.get_streaming_content(self.selected_agent_id)
|
|
1717
|
+
if streaming_content and streaming_content.strip():
|
|
1718
|
+
self.tracer.clear_streaming_content(self.selected_agent_id)
|
|
1719
|
+
self.tracer.interrupted_content[self.selected_agent_id] = streaming_content
|
|
1720
|
+
self.tracer.log_chat_message(
|
|
1721
|
+
content=streaming_content,
|
|
1722
|
+
role="assistant",
|
|
1723
|
+
agent_id=self.selected_agent_id,
|
|
1724
|
+
metadata={"interrupted": True},
|
|
1725
|
+
)
|
|
1726
|
+
|
|
1727
|
+
try:
|
|
1728
|
+
from strix.tools.agents_graph.agents_graph_actions import _agent_instances
|
|
1729
|
+
|
|
1730
|
+
if self.selected_agent_id in _agent_instances:
|
|
1731
|
+
agent_instance = _agent_instances[self.selected_agent_id]
|
|
1732
|
+
if hasattr(agent_instance, "cancel_current_execution"):
|
|
1733
|
+
agent_instance.cancel_current_execution()
|
|
1734
|
+
except (ImportError, AttributeError, KeyError):
|
|
1735
|
+
pass
|
|
1736
|
+
|
|
1095
1737
|
if self.tracer:
|
|
1096
1738
|
self.tracer.log_chat_message(
|
|
1097
1739
|
content=message,
|
|
@@ -1159,12 +1801,14 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
1159
1801
|
self.push_screen(QuitScreen())
|
|
1160
1802
|
|
|
1161
1803
|
def action_stop_selected_agent(self) -> None:
|
|
1162
|
-
if
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1804
|
+
if self.show_splash or not self.is_mounted:
|
|
1805
|
+
return
|
|
1806
|
+
|
|
1807
|
+
if len(self.screen_stack) > 1:
|
|
1808
|
+
self.pop_screen()
|
|
1809
|
+
return
|
|
1810
|
+
|
|
1811
|
+
if not self.selected_agent_id:
|
|
1168
1812
|
return
|
|
1169
1813
|
|
|
1170
1814
|
agent_name, should_stop = self._validate_agent_for_stopping()
|
|
@@ -1224,9 +1868,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
1224
1868
|
logging.exception(f"Failed to stop agent {agent_id}")
|
|
1225
1869
|
|
|
1226
1870
|
def action_custom_quit(self) -> None:
|
|
1227
|
-
for agent_id in list(self._agent_verb_timers.keys()):
|
|
1228
|
-
self._stop_agent_verb_timer(agent_id)
|
|
1229
|
-
|
|
1230
1871
|
if self._scan_thread and self._scan_thread.is_alive():
|
|
1231
1872
|
self._scan_stop_event.set()
|
|
1232
1873
|
|
|
@@ -1254,18 +1895,22 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|
|
1254
1895
|
else:
|
|
1255
1896
|
return True
|
|
1256
1897
|
|
|
1257
|
-
def
|
|
1898
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
1899
|
+
if self.show_splash or not self.is_mounted:
|
|
1900
|
+
return
|
|
1901
|
+
|
|
1258
1902
|
try:
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
widget.update(safe_text)
|
|
1264
|
-
except Exception: # noqa: BLE001
|
|
1265
|
-
import re
|
|
1903
|
+
sidebar = self.query_one("#sidebar", Vertical)
|
|
1904
|
+
chat_area = self.query_one("#chat_area_container", Vertical)
|
|
1905
|
+
except (ValueError, Exception):
|
|
1906
|
+
return
|
|
1266
1907
|
|
|
1267
|
-
|
|
1268
|
-
|
|
1908
|
+
if event.size.width < self.SIDEBAR_MIN_WIDTH:
|
|
1909
|
+
sidebar.add_class("-hidden")
|
|
1910
|
+
chat_area.add_class("-full-width")
|
|
1911
|
+
else:
|
|
1912
|
+
sidebar.remove_class("-hidden")
|
|
1913
|
+
chat_area.remove_class("-full-width")
|
|
1269
1914
|
|
|
1270
1915
|
|
|
1271
1916
|
async def run_tui(args: argparse.Namespace) -> None:
|