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.
Files changed (117) hide show
  1. strix/agents/StrixAgent/strix_agent.py +3 -3
  2. strix/agents/StrixAgent/system_prompt.jinja +30 -26
  3. strix/agents/base_agent.py +159 -75
  4. strix/agents/state.py +5 -2
  5. strix/config/__init__.py +12 -0
  6. strix/config/config.py +172 -0
  7. strix/interface/assets/tui_styles.tcss +195 -230
  8. strix/interface/cli.py +16 -41
  9. strix/interface/main.py +151 -74
  10. strix/interface/streaming_parser.py +119 -0
  11. strix/interface/tool_components/__init__.py +4 -0
  12. strix/interface/tool_components/agent_message_renderer.py +190 -0
  13. strix/interface/tool_components/agents_graph_renderer.py +54 -38
  14. strix/interface/tool_components/base_renderer.py +68 -36
  15. strix/interface/tool_components/browser_renderer.py +106 -91
  16. strix/interface/tool_components/file_edit_renderer.py +117 -36
  17. strix/interface/tool_components/finish_renderer.py +43 -10
  18. strix/interface/tool_components/notes_renderer.py +63 -38
  19. strix/interface/tool_components/proxy_renderer.py +133 -92
  20. strix/interface/tool_components/python_renderer.py +121 -8
  21. strix/interface/tool_components/registry.py +19 -12
  22. strix/interface/tool_components/reporting_renderer.py +196 -28
  23. strix/interface/tool_components/scan_info_renderer.py +22 -19
  24. strix/interface/tool_components/terminal_renderer.py +270 -90
  25. strix/interface/tool_components/thinking_renderer.py +8 -6
  26. strix/interface/tool_components/todo_renderer.py +225 -0
  27. strix/interface/tool_components/user_message_renderer.py +26 -19
  28. strix/interface/tool_components/web_search_renderer.py +7 -6
  29. strix/interface/tui.py +907 -262
  30. strix/interface/utils.py +236 -4
  31. strix/llm/__init__.py +6 -2
  32. strix/llm/config.py +8 -5
  33. strix/llm/dedupe.py +217 -0
  34. strix/llm/llm.py +209 -356
  35. strix/llm/memory_compressor.py +6 -5
  36. strix/llm/utils.py +17 -8
  37. strix/runtime/__init__.py +12 -3
  38. strix/runtime/docker_runtime.py +121 -202
  39. strix/runtime/tool_server.py +55 -95
  40. strix/skills/README.md +64 -0
  41. strix/skills/__init__.py +110 -0
  42. strix/{prompts → skills}/frameworks/nextjs.jinja +26 -0
  43. strix/skills/scan_modes/deep.jinja +145 -0
  44. strix/skills/scan_modes/quick.jinja +63 -0
  45. strix/skills/scan_modes/standard.jinja +91 -0
  46. strix/telemetry/README.md +38 -0
  47. strix/telemetry/__init__.py +7 -1
  48. strix/telemetry/posthog.py +137 -0
  49. strix/telemetry/tracer.py +194 -54
  50. strix/tools/__init__.py +11 -4
  51. strix/tools/agents_graph/agents_graph_actions.py +20 -21
  52. strix/tools/agents_graph/agents_graph_actions_schema.xml +8 -8
  53. strix/tools/browser/browser_actions.py +10 -6
  54. strix/tools/browser/browser_actions_schema.xml +6 -1
  55. strix/tools/browser/browser_instance.py +96 -48
  56. strix/tools/browser/tab_manager.py +121 -102
  57. strix/tools/context.py +12 -0
  58. strix/tools/executor.py +63 -4
  59. strix/tools/file_edit/file_edit_actions.py +6 -3
  60. strix/tools/file_edit/file_edit_actions_schema.xml +45 -3
  61. strix/tools/finish/finish_actions.py +80 -105
  62. strix/tools/finish/finish_actions_schema.xml +121 -14
  63. strix/tools/notes/notes_actions.py +6 -33
  64. strix/tools/notes/notes_actions_schema.xml +50 -46
  65. strix/tools/proxy/proxy_actions.py +14 -2
  66. strix/tools/proxy/proxy_actions_schema.xml +0 -1
  67. strix/tools/proxy/proxy_manager.py +28 -16
  68. strix/tools/python/python_actions.py +2 -2
  69. strix/tools/python/python_actions_schema.xml +9 -1
  70. strix/tools/python/python_instance.py +39 -37
  71. strix/tools/python/python_manager.py +43 -31
  72. strix/tools/registry.py +73 -12
  73. strix/tools/reporting/reporting_actions.py +218 -31
  74. strix/tools/reporting/reporting_actions_schema.xml +256 -8
  75. strix/tools/terminal/terminal_actions.py +2 -2
  76. strix/tools/terminal/terminal_actions_schema.xml +6 -0
  77. strix/tools/terminal/terminal_manager.py +41 -30
  78. strix/tools/thinking/thinking_actions_schema.xml +27 -25
  79. strix/tools/todo/__init__.py +18 -0
  80. strix/tools/todo/todo_actions.py +568 -0
  81. strix/tools/todo/todo_actions_schema.xml +225 -0
  82. strix/utils/__init__.py +0 -0
  83. strix/utils/resource_paths.py +13 -0
  84. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/METADATA +90 -65
  85. strix_agent-0.6.2.dist-info/RECORD +134 -0
  86. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/WHEEL +1 -1
  87. strix/llm/request_queue.py +0 -87
  88. strix/prompts/README.md +0 -64
  89. strix/prompts/__init__.py +0 -109
  90. strix_agent-0.4.0.dist-info/RECORD +0 -118
  91. /strix/{prompts → skills}/cloud/.gitkeep +0 -0
  92. /strix/{prompts → skills}/coordination/root_agent.jinja +0 -0
  93. /strix/{prompts → skills}/custom/.gitkeep +0 -0
  94. /strix/{prompts → skills}/frameworks/fastapi.jinja +0 -0
  95. /strix/{prompts → skills}/protocols/graphql.jinja +0 -0
  96. /strix/{prompts → skills}/reconnaissance/.gitkeep +0 -0
  97. /strix/{prompts → skills}/technologies/firebase_firestore.jinja +0 -0
  98. /strix/{prompts → skills}/technologies/supabase.jinja +0 -0
  99. /strix/{prompts → skills}/vulnerabilities/authentication_jwt.jinja +0 -0
  100. /strix/{prompts → skills}/vulnerabilities/broken_function_level_authorization.jinja +0 -0
  101. /strix/{prompts → skills}/vulnerabilities/business_logic.jinja +0 -0
  102. /strix/{prompts → skills}/vulnerabilities/csrf.jinja +0 -0
  103. /strix/{prompts → skills}/vulnerabilities/idor.jinja +0 -0
  104. /strix/{prompts → skills}/vulnerabilities/information_disclosure.jinja +0 -0
  105. /strix/{prompts → skills}/vulnerabilities/insecure_file_uploads.jinja +0 -0
  106. /strix/{prompts → skills}/vulnerabilities/mass_assignment.jinja +0 -0
  107. /strix/{prompts → skills}/vulnerabilities/open_redirect.jinja +0 -0
  108. /strix/{prompts → skills}/vulnerabilities/path_traversal_lfi_rfi.jinja +0 -0
  109. /strix/{prompts → skills}/vulnerabilities/race_conditions.jinja +0 -0
  110. /strix/{prompts → skills}/vulnerabilities/rce.jinja +0 -0
  111. /strix/{prompts → skills}/vulnerabilities/sql_injection.jinja +0 -0
  112. /strix/{prompts → skills}/vulnerabilities/ssrf.jinja +0 -0
  113. /strix/{prompts → skills}/vulnerabilities/subdomain_takeover.jinja +0 -0
  114. /strix/{prompts → skills}/vulnerabilities/xss.jinja +0 -0
  115. /strix/{prompts → skills}/vulnerabilities/xxe.jinja +0 -0
  116. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/entry_points.txt +0 -0
  117. {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, cast
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 build_live_stats_text
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.45, self._animate_start_line)
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
- emphasize = phase % 2 == 1
145
- base_style = Style(color="white", dim=not emphasize, bold=emphasize)
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
- text = Text("Starting ", style=base_style)
149
- text.append("Strix", style=strix_style)
150
- text.append(" Cybersecurity Agent", style=base_style)
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("🦉 Quit Strix? ", id="quit_title"),
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._action_verbs = [
297
- "Generating",
298
- "Scanning",
299
- "Analyzing",
300
- "Probing",
301
- "Hacking",
302
- "Testing",
303
- "Exploiting",
304
- "Investigating",
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
- llm_config = LLMConfig()
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
- sidebar = Vertical(agents_tree, stats_display, id="sidebar")
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.5, self._update_ui_from_tracer)
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._expand_all_agent_nodes()
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
- agent_name = f"{status_icon} {escape_markup(agent_name_raw)}"
512
-
513
- if status == "running":
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 _update_chat_view(self) -> None:
532
- if len(self.screen_stack) > 1 or self.show_splash:
533
- return
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
- if not self.is_mounted:
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
- if not self.selected_agent_id:
552
- content, css_class = self._get_chat_placeholder_content(
553
- "Select an agent from the tree to see its activity.", "placeholder-no-agent"
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._update_static_content_safe(chat_display, content)
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[str, str]:
986
+ ) -> tuple[Text, str]:
580
987
  self._displayed_events = [placeholder_class]
581
- return message, f"chat-placeholder {placeholder_class}"
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
- chat_content = self._render_chat_content(event["data"])
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
- tool_content = self._render_tool_content_simple(event["data"])
595
- if tool_content:
596
- content_lines.append(tool_content)
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
- return "\n\n".join(content_lines)
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
- status = agent_data.get("status", "running")
1180
+ content, keymap, should_animate = self._get_status_display_content(
1181
+ self.selected_agent_id, agent_data
1182
+ )
619
1183
 
620
- if status == "stopping":
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 = build_live_stats_text(self.tracer)
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
- title="📊 Live Stats",
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 _get_agent_verb(self, agent_id: str) -> str:
696
- if agent_id not in self._agent_verbs:
697
- self._agent_verbs[agent_id] = random.choice(self._action_verbs) # nosec B311 # noqa: S311
698
- return self._agent_verbs[agent_id]
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
- current_verb = self._agent_verbs[agent_id]
717
- available_verbs = [verb for verb in self._action_verbs if verb != current_verb]
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
- def _get_animated_verb_text(self, agent_id: str, verb: str) -> str:
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
- dot_count = self._agent_dot_states[agent_id]
728
- dots = "." * dot_count
729
- return f"{verb}{dots}"
1235
+ if not vulnerabilities:
1236
+ self._safe_widget_operation(vuln_panel.add_class, "hidden")
1237
+ return
730
1238
 
731
- def _get_animated_waiting_text(self, agent_id: str) -> str:
732
- if agent_id not in self._agent_dot_states:
733
- self._agent_dot_states[agent_id] = 0
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
- dot_count = self._agent_dot_states[agent_id]
736
- dots = "." * dot_count
1289
+ text.append(" ")
1290
+ return text
737
1291
 
738
- return f"Waiting{dots}"
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.6, self._animate_dots)
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
- for agent_id, agent_data in self.tracer.agents.items():
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
- current_dots = self._agent_dot_states.get(agent_id, 0)
757
- self._agent_dot_states[agent_id] = (current_dots + 1) % 4
758
-
759
- if (
760
- has_active_agents
761
- and self.selected_agent_id
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
- for agent_id in list(self._agent_dot_states.keys()):
771
- if agent_id not in self.tracer.agents or self.tracer.agents[agent_id].get(
772
- "status"
773
- ) not in ["running", "waiting"]:
774
- del self._agent_dot_states[agent_id]
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
- agent_name = f"{status_icon} {escape_markup(agent_name_raw)}"
875
-
876
- if status in ["running", "waiting"]:
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
- agent_name = f"{status_icon} {escape_markup(agent_name_raw)}"
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
- self._expand_all_agent_nodes()
1581
+ parent_node.expand()
987
1582
 
988
- def _render_chat_content(self, msg_data: dict[str, Any]) -> str:
1583
+ def _render_chat_content(self, msg_data: dict[str, Any]) -> Any:
989
1584
  role = msg_data.get("role")
990
- content = escape_markup(msg_data.get("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
- def _render_tool_content_simple(self, tool_data: dict[str, Any]) -> str:
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
- content = str(widget.renderable)
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
- lines = [f"→ Using tool [bold blue]{escape_markup(tool_name)}[/] {status_icon}"]
1622
+ text = Text()
1051
1623
 
1052
- if args:
1053
- for k, v in list(args.items())[:2]:
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
- if status in ["completed", "failed", "error"] and result:
1060
- result_str = str(result)
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
- content = "\n".join(lines)
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
- lines = content.split("\n")
1068
- bordered_lines = [f"[{color}]▍[/{color}] {line}" for line in lines]
1069
- return "\n".join(bordered_lines)
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
- self.show_splash
1164
- or not self.is_mounted
1165
- or len(self.screen_stack) > 1
1166
- or not self.selected_agent_id
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 _update_static_content_safe(self, widget: Static, content: str) -> None:
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
- widget.update(content)
1260
- except Exception: # noqa: BLE001
1261
- try:
1262
- safe_text = Text.from_markup(content)
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
- plain_text = re.sub(r"\[.*?\]", "", content)
1268
- widget.update(plain_text)
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: