strix-agent 0.4.0__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 (118) hide show
  1. strix/__init__.py +0 -0
  2. strix/agents/StrixAgent/__init__.py +4 -0
  3. strix/agents/StrixAgent/strix_agent.py +89 -0
  4. strix/agents/StrixAgent/system_prompt.jinja +404 -0
  5. strix/agents/__init__.py +10 -0
  6. strix/agents/base_agent.py +518 -0
  7. strix/agents/state.py +163 -0
  8. strix/interface/__init__.py +4 -0
  9. strix/interface/assets/tui_styles.tcss +694 -0
  10. strix/interface/cli.py +230 -0
  11. strix/interface/main.py +500 -0
  12. strix/interface/tool_components/__init__.py +39 -0
  13. strix/interface/tool_components/agents_graph_renderer.py +123 -0
  14. strix/interface/tool_components/base_renderer.py +62 -0
  15. strix/interface/tool_components/browser_renderer.py +120 -0
  16. strix/interface/tool_components/file_edit_renderer.py +99 -0
  17. strix/interface/tool_components/finish_renderer.py +31 -0
  18. strix/interface/tool_components/notes_renderer.py +108 -0
  19. strix/interface/tool_components/proxy_renderer.py +255 -0
  20. strix/interface/tool_components/python_renderer.py +34 -0
  21. strix/interface/tool_components/registry.py +72 -0
  22. strix/interface/tool_components/reporting_renderer.py +53 -0
  23. strix/interface/tool_components/scan_info_renderer.py +64 -0
  24. strix/interface/tool_components/terminal_renderer.py +131 -0
  25. strix/interface/tool_components/thinking_renderer.py +29 -0
  26. strix/interface/tool_components/user_message_renderer.py +43 -0
  27. strix/interface/tool_components/web_search_renderer.py +28 -0
  28. strix/interface/tui.py +1274 -0
  29. strix/interface/utils.py +559 -0
  30. strix/llm/__init__.py +15 -0
  31. strix/llm/config.py +20 -0
  32. strix/llm/llm.py +465 -0
  33. strix/llm/memory_compressor.py +212 -0
  34. strix/llm/request_queue.py +87 -0
  35. strix/llm/utils.py +87 -0
  36. strix/prompts/README.md +64 -0
  37. strix/prompts/__init__.py +109 -0
  38. strix/prompts/cloud/.gitkeep +0 -0
  39. strix/prompts/coordination/root_agent.jinja +41 -0
  40. strix/prompts/custom/.gitkeep +0 -0
  41. strix/prompts/frameworks/fastapi.jinja +142 -0
  42. strix/prompts/frameworks/nextjs.jinja +126 -0
  43. strix/prompts/protocols/graphql.jinja +215 -0
  44. strix/prompts/reconnaissance/.gitkeep +0 -0
  45. strix/prompts/technologies/firebase_firestore.jinja +177 -0
  46. strix/prompts/technologies/supabase.jinja +189 -0
  47. strix/prompts/vulnerabilities/authentication_jwt.jinja +147 -0
  48. strix/prompts/vulnerabilities/broken_function_level_authorization.jinja +146 -0
  49. strix/prompts/vulnerabilities/business_logic.jinja +171 -0
  50. strix/prompts/vulnerabilities/csrf.jinja +174 -0
  51. strix/prompts/vulnerabilities/idor.jinja +195 -0
  52. strix/prompts/vulnerabilities/information_disclosure.jinja +222 -0
  53. strix/prompts/vulnerabilities/insecure_file_uploads.jinja +188 -0
  54. strix/prompts/vulnerabilities/mass_assignment.jinja +141 -0
  55. strix/prompts/vulnerabilities/open_redirect.jinja +177 -0
  56. strix/prompts/vulnerabilities/path_traversal_lfi_rfi.jinja +142 -0
  57. strix/prompts/vulnerabilities/race_conditions.jinja +164 -0
  58. strix/prompts/vulnerabilities/rce.jinja +154 -0
  59. strix/prompts/vulnerabilities/sql_injection.jinja +151 -0
  60. strix/prompts/vulnerabilities/ssrf.jinja +135 -0
  61. strix/prompts/vulnerabilities/subdomain_takeover.jinja +155 -0
  62. strix/prompts/vulnerabilities/xss.jinja +169 -0
  63. strix/prompts/vulnerabilities/xxe.jinja +184 -0
  64. strix/runtime/__init__.py +19 -0
  65. strix/runtime/docker_runtime.py +399 -0
  66. strix/runtime/runtime.py +29 -0
  67. strix/runtime/tool_server.py +205 -0
  68. strix/telemetry/__init__.py +4 -0
  69. strix/telemetry/tracer.py +337 -0
  70. strix/tools/__init__.py +64 -0
  71. strix/tools/agents_graph/__init__.py +16 -0
  72. strix/tools/agents_graph/agents_graph_actions.py +621 -0
  73. strix/tools/agents_graph/agents_graph_actions_schema.xml +226 -0
  74. strix/tools/argument_parser.py +121 -0
  75. strix/tools/browser/__init__.py +4 -0
  76. strix/tools/browser/browser_actions.py +236 -0
  77. strix/tools/browser/browser_actions_schema.xml +183 -0
  78. strix/tools/browser/browser_instance.py +533 -0
  79. strix/tools/browser/tab_manager.py +342 -0
  80. strix/tools/executor.py +305 -0
  81. strix/tools/file_edit/__init__.py +4 -0
  82. strix/tools/file_edit/file_edit_actions.py +141 -0
  83. strix/tools/file_edit/file_edit_actions_schema.xml +128 -0
  84. strix/tools/finish/__init__.py +4 -0
  85. strix/tools/finish/finish_actions.py +174 -0
  86. strix/tools/finish/finish_actions_schema.xml +45 -0
  87. strix/tools/notes/__init__.py +14 -0
  88. strix/tools/notes/notes_actions.py +191 -0
  89. strix/tools/notes/notes_actions_schema.xml +150 -0
  90. strix/tools/proxy/__init__.py +20 -0
  91. strix/tools/proxy/proxy_actions.py +101 -0
  92. strix/tools/proxy/proxy_actions_schema.xml +267 -0
  93. strix/tools/proxy/proxy_manager.py +785 -0
  94. strix/tools/python/__init__.py +4 -0
  95. strix/tools/python/python_actions.py +47 -0
  96. strix/tools/python/python_actions_schema.xml +131 -0
  97. strix/tools/python/python_instance.py +172 -0
  98. strix/tools/python/python_manager.py +131 -0
  99. strix/tools/registry.py +196 -0
  100. strix/tools/reporting/__init__.py +6 -0
  101. strix/tools/reporting/reporting_actions.py +63 -0
  102. strix/tools/reporting/reporting_actions_schema.xml +30 -0
  103. strix/tools/terminal/__init__.py +4 -0
  104. strix/tools/terminal/terminal_actions.py +35 -0
  105. strix/tools/terminal/terminal_actions_schema.xml +146 -0
  106. strix/tools/terminal/terminal_manager.py +151 -0
  107. strix/tools/terminal/terminal_session.py +447 -0
  108. strix/tools/thinking/__init__.py +4 -0
  109. strix/tools/thinking/thinking_actions.py +18 -0
  110. strix/tools/thinking/thinking_actions_schema.xml +52 -0
  111. strix/tools/web_search/__init__.py +4 -0
  112. strix/tools/web_search/web_search_actions.py +80 -0
  113. strix/tools/web_search/web_search_actions_schema.xml +83 -0
  114. strix_agent-0.4.0.dist-info/LICENSE +201 -0
  115. strix_agent-0.4.0.dist-info/METADATA +282 -0
  116. strix_agent-0.4.0.dist-info/RECORD +118 -0
  117. strix_agent-0.4.0.dist-info/WHEEL +4 -0
  118. strix_agent-0.4.0.dist-info/entry_points.txt +3 -0
strix/interface/tui.py ADDED
@@ -0,0 +1,1274 @@
1
+ import argparse
2
+ import asyncio
3
+ import atexit
4
+ import logging
5
+ import random
6
+ import signal
7
+ import sys
8
+ import threading
9
+ from collections.abc import Callable
10
+ from importlib.metadata import PackageNotFoundError
11
+ from importlib.metadata import version as pkg_version
12
+ from typing import TYPE_CHECKING, Any, ClassVar, cast
13
+
14
+
15
+ if TYPE_CHECKING:
16
+ from textual.timer import Timer
17
+
18
+ from rich.align import Align
19
+ from rich.console import Group
20
+ from rich.markup import escape as rich_escape
21
+ from rich.panel import Panel
22
+ from rich.style import Style
23
+ from rich.text import Text
24
+ from textual import events, on
25
+ from textual.app import App, ComposeResult
26
+ from textual.binding import Binding
27
+ from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
28
+ from textual.reactive import reactive
29
+ from textual.screen import ModalScreen
30
+ from textual.widgets import Button, Label, Static, TextArea, Tree
31
+ from textual.widgets.tree import TreeNode
32
+
33
+ from strix.agents.StrixAgent import StrixAgent
34
+ from strix.interface.utils import build_live_stats_text
35
+ from strix.llm.config import LLMConfig
36
+ from strix.telemetry.tracer import Tracer, set_global_tracer
37
+
38
+
39
+ def escape_markup(text: str) -> str:
40
+ return cast("str", rich_escape(text))
41
+
42
+
43
+ def get_package_version() -> str:
44
+ try:
45
+ return pkg_version("strix-agent")
46
+ except PackageNotFoundError:
47
+ return "dev"
48
+
49
+
50
+ class ChatTextArea(TextArea): # type: ignore[misc]
51
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
52
+ super().__init__(*args, **kwargs)
53
+ self._app_reference: StrixTUIApp | None = None
54
+
55
+ def set_app_reference(self, app: "StrixTUIApp") -> None:
56
+ self._app_reference = app
57
+
58
+ def _on_key(self, event: events.Key) -> None:
59
+ if event.key == "enter" and self._app_reference:
60
+ text_content = str(self.text) # type: ignore[has-type]
61
+ message = text_content.strip()
62
+ if message:
63
+ self.text = ""
64
+
65
+ self._app_reference._send_user_message(message)
66
+
67
+ event.prevent_default()
68
+ return
69
+
70
+ super()._on_key(event)
71
+
72
+
73
+ class SplashScreen(Static): # type: ignore[misc]
74
+ PRIMARY_GREEN = "#22c55e"
75
+ BANNER = (
76
+ " ███████╗████████╗██████╗ ██╗██╗ ██╗\n"
77
+ " ██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝\n"
78
+ " ███████╗ ██║ ██████╔╝██║ ╚███╔╝\n"
79
+ " ╚════██║ ██║ ██╔══██╗██║ ██╔██╗\n"
80
+ " ███████║ ██║ ██║ ██║██║██╔╝ ██╗\n"
81
+ " ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝"
82
+ )
83
+
84
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
85
+ super().__init__(*args, **kwargs)
86
+ self._animation_step = 0
87
+ self._animation_timer: Timer | None = None
88
+ self._panel_static: Static | None = None
89
+ self._version = "dev"
90
+
91
+ def compose(self) -> ComposeResult:
92
+ self._version = get_package_version()
93
+ self._animation_step = 0
94
+ start_line = self._build_start_line_text(self._animation_step)
95
+ panel = self._build_panel(start_line)
96
+
97
+ panel_static = Static(panel, id="splash_content")
98
+ self._panel_static = panel_static
99
+ yield panel_static
100
+
101
+ def on_mount(self) -> None:
102
+ self._animation_timer = self.set_interval(0.45, self._animate_start_line)
103
+
104
+ def on_unmount(self) -> None:
105
+ if self._animation_timer is not None:
106
+ self._animation_timer.stop()
107
+ self._animation_timer = None
108
+
109
+ def _animate_start_line(self) -> None:
110
+ if not self._panel_static:
111
+ return
112
+
113
+ self._animation_step += 1
114
+ start_line = self._build_start_line_text(self._animation_step)
115
+ panel = self._build_panel(start_line)
116
+ self._panel_static.update(panel)
117
+
118
+ def _build_panel(self, start_line: Text) -> Panel:
119
+ content = Group(
120
+ Align.center(Text(self.BANNER.strip("\n"), style=self.PRIMARY_GREEN, justify="center")),
121
+ Align.center(Text(" ")),
122
+ Align.center(self._build_welcome_text()),
123
+ Align.center(self._build_version_text()),
124
+ Align.center(self._build_tagline_text()),
125
+ Align.center(Text(" ")),
126
+ Align.center(start_line.copy()),
127
+ )
128
+
129
+ return Panel.fit(content, border_style=self.PRIMARY_GREEN, padding=(1, 6))
130
+
131
+ def _build_welcome_text(self) -> Text:
132
+ text = Text("Welcome to ", style=Style(color="white", bold=True))
133
+ text.append("Strix", style=Style(color=self.PRIMARY_GREEN, bold=True))
134
+ text.append("!", style=Style(color="white", bold=True))
135
+ return text
136
+
137
+ def _build_version_text(self) -> Text:
138
+ return Text(f"v{self._version}", style=Style(color="white", dim=True))
139
+
140
+ def _build_tagline_text(self) -> Text:
141
+ return Text("Open-source AI hackers for your apps", style=Style(color="white", dim=True))
142
+
143
+ 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))
147
+
148
+ text = Text("Starting ", style=base_style)
149
+ text.append("Strix", style=strix_style)
150
+ text.append(" Cybersecurity Agent", style=base_style)
151
+
152
+ return text
153
+
154
+
155
+ class HelpScreen(ModalScreen): # type: ignore[misc]
156
+ def compose(self) -> ComposeResult:
157
+ yield Grid(
158
+ Label("🦉 Strix Help", id="help_title"),
159
+ Label(
160
+ "F1 Help\nCtrl+Q/C Quit\nESC Stop Agent\n"
161
+ "Enter Send message to agent\nTab Switch panels\n↑/↓ Navigate tree",
162
+ id="help_content",
163
+ ),
164
+ id="dialog",
165
+ )
166
+
167
+ def on_key(self, _event: events.Key) -> None:
168
+ self.app.pop_screen()
169
+
170
+
171
+ class StopAgentScreen(ModalScreen): # type: ignore[misc]
172
+ def __init__(self, agent_name: str, agent_id: str):
173
+ super().__init__()
174
+ self.agent_name = agent_name
175
+ self.agent_id = agent_id
176
+
177
+ def compose(self) -> ComposeResult:
178
+ yield Grid(
179
+ Label(f"🛑 Stop '{self.agent_name}'?", id="stop_agent_title"),
180
+ Grid(
181
+ Button("Yes", variant="error", id="stop_agent"),
182
+ Button("No", variant="default", id="cancel_stop"),
183
+ id="stop_agent_buttons",
184
+ ),
185
+ id="stop_agent_dialog",
186
+ )
187
+
188
+ def on_mount(self) -> None:
189
+ cancel_button = self.query_one("#cancel_stop", Button)
190
+ cancel_button.focus()
191
+
192
+ def on_key(self, event: events.Key) -> None:
193
+ if event.key in ("left", "right", "up", "down"):
194
+ focused = self.focused
195
+
196
+ if focused and focused.id == "stop_agent":
197
+ cancel_button = self.query_one("#cancel_stop", Button)
198
+ cancel_button.focus()
199
+ else:
200
+ stop_button = self.query_one("#stop_agent", Button)
201
+ stop_button.focus()
202
+
203
+ event.prevent_default()
204
+ elif event.key == "enter":
205
+ focused = self.focused
206
+ if focused and isinstance(focused, Button):
207
+ focused.press()
208
+ event.prevent_default()
209
+ elif event.key == "escape":
210
+ self.app.pop_screen()
211
+ event.prevent_default()
212
+
213
+ def on_button_pressed(self, event: Button.Pressed) -> None:
214
+ if event.button.id == "stop_agent":
215
+ self.app.action_confirm_stop_agent(self.agent_id)
216
+ else:
217
+ self.app.pop_screen()
218
+
219
+
220
+ class QuitScreen(ModalScreen): # type: ignore[misc]
221
+ def compose(self) -> ComposeResult:
222
+ yield Grid(
223
+ Label("🦉 Quit Strix? ", id="quit_title"),
224
+ Grid(
225
+ Button("Yes", variant="error", id="quit"),
226
+ Button("No", variant="default", id="cancel"),
227
+ id="quit_buttons",
228
+ ),
229
+ id="quit_dialog",
230
+ )
231
+
232
+ def on_mount(self) -> None:
233
+ cancel_button = self.query_one("#cancel", Button)
234
+ cancel_button.focus()
235
+
236
+ def on_key(self, event: events.Key) -> None:
237
+ if event.key in ("left", "right", "up", "down"):
238
+ focused = self.focused
239
+
240
+ if focused and focused.id == "quit":
241
+ cancel_button = self.query_one("#cancel", Button)
242
+ cancel_button.focus()
243
+ else:
244
+ quit_button = self.query_one("#quit", Button)
245
+ quit_button.focus()
246
+
247
+ event.prevent_default()
248
+ elif event.key == "enter":
249
+ focused = self.focused
250
+ if focused and isinstance(focused, Button):
251
+ focused.press()
252
+ event.prevent_default()
253
+ elif event.key == "escape":
254
+ self.app.pop_screen()
255
+ event.prevent_default()
256
+
257
+ def on_button_pressed(self, event: Button.Pressed) -> None:
258
+ if event.button.id == "quit":
259
+ self.app.action_custom_quit()
260
+ else:
261
+ self.app.pop_screen()
262
+
263
+
264
+ class StrixTUIApp(App): # type: ignore[misc]
265
+ CSS_PATH = "assets/tui_styles.tcss"
266
+
267
+ selected_agent_id: reactive[str | None] = reactive(default=None)
268
+ show_splash: reactive[bool] = reactive(default=True)
269
+
270
+ BINDINGS: ClassVar[list[Binding]] = [
271
+ Binding("f1", "toggle_help", "Help", priority=True),
272
+ Binding("ctrl+q", "request_quit", "Quit", priority=True),
273
+ Binding("ctrl+c", "request_quit", "Quit", priority=True),
274
+ Binding("escape", "stop_selected_agent", "Stop Agent", priority=True),
275
+ ]
276
+
277
+ def __init__(self, args: argparse.Namespace):
278
+ super().__init__()
279
+ self.args = args
280
+ self.scan_config = self._build_scan_config(args)
281
+ self.agent_config = self._build_agent_config(args)
282
+
283
+ self.tracer = Tracer(self.scan_config["run_name"])
284
+ self.tracer.set_scan_config(self.scan_config)
285
+ set_global_tracer(self.tracer)
286
+
287
+ self.agent_nodes: dict[str, TreeNode] = {}
288
+
289
+ self._displayed_agents: set[str] = set()
290
+ self._displayed_events: list[str] = []
291
+
292
+ self._scan_thread: threading.Thread | None = None
293
+ self._scan_stop_event = threading.Event()
294
+ self._scan_completed = threading.Event()
295
+
296
+ self._action_verbs = [
297
+ "Generating",
298
+ "Scanning",
299
+ "Analyzing",
300
+ "Probing",
301
+ "Hacking",
302
+ "Testing",
303
+ "Exploiting",
304
+ "Investigating",
305
+ ]
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
+ self._dot_animation_timer: Any | None = None
310
+
311
+ self._setup_cleanup_handlers()
312
+
313
+ def _build_scan_config(self, args: argparse.Namespace) -> dict[str, Any]:
314
+ return {
315
+ "scan_id": args.run_name,
316
+ "targets": args.targets_info,
317
+ "user_instructions": args.instruction or "",
318
+ "run_name": args.run_name,
319
+ }
320
+
321
+ def _build_agent_config(self, args: argparse.Namespace) -> dict[str, Any]:
322
+ llm_config = LLMConfig()
323
+
324
+ config = {
325
+ "llm_config": llm_config,
326
+ "max_iterations": 300,
327
+ }
328
+
329
+ if getattr(args, "local_sources", None):
330
+ config["local_sources"] = args.local_sources
331
+
332
+ return config
333
+
334
+ def _setup_cleanup_handlers(self) -> None:
335
+ def cleanup_on_exit() -> None:
336
+ self.tracer.cleanup()
337
+
338
+ def signal_handler(_signum: int, _frame: Any) -> None:
339
+ self.tracer.cleanup()
340
+ sys.exit(0)
341
+
342
+ atexit.register(cleanup_on_exit)
343
+ signal.signal(signal.SIGINT, signal_handler)
344
+ signal.signal(signal.SIGTERM, signal_handler)
345
+ if hasattr(signal, "SIGHUP"):
346
+ signal.signal(signal.SIGHUP, signal_handler)
347
+
348
+ def compose(self) -> ComposeResult:
349
+ if self.show_splash:
350
+ yield SplashScreen(id="splash_screen")
351
+
352
+ def watch_show_splash(self, show_splash: bool) -> None:
353
+ if not show_splash and self.is_mounted:
354
+ try:
355
+ splash = self.query_one("#splash_screen")
356
+ splash.remove()
357
+ except ValueError:
358
+ pass
359
+
360
+ main_container = Vertical(id="main_container")
361
+
362
+ self.mount(main_container)
363
+
364
+ content_container = Horizontal(id="content_container")
365
+ main_container.mount(content_container)
366
+
367
+ chat_area_container = Vertical(id="chat_area_container")
368
+
369
+ chat_display = Static("", id="chat_display")
370
+ chat_history = VerticalScroll(chat_display, id="chat_history")
371
+ chat_history.can_focus = True
372
+
373
+ status_text = Static("", id="status_text")
374
+ keymap_indicator = Static("", id="keymap_indicator")
375
+
376
+ agent_status_display = Horizontal(
377
+ status_text, keymap_indicator, id="agent_status_display", classes="hidden"
378
+ )
379
+
380
+ chat_prompt = Static("> ", id="chat_prompt")
381
+ chat_input = ChatTextArea(
382
+ "",
383
+ id="chat_input",
384
+ show_line_numbers=False,
385
+ )
386
+ chat_input.set_app_reference(self)
387
+ chat_input_container = Horizontal(chat_prompt, chat_input, id="chat_input_container")
388
+
389
+ agents_tree = Tree("🤖 Active Agents", id="agents_tree")
390
+ agents_tree.root.expand()
391
+ agents_tree.show_root = False
392
+
393
+ agents_tree.show_guide = True
394
+ agents_tree.guide_depth = 3
395
+ agents_tree.guide_style = "dashed"
396
+
397
+ stats_display = Static("", id="stats_display")
398
+
399
+ sidebar = Vertical(agents_tree, stats_display, id="sidebar")
400
+
401
+ content_container.mount(chat_area_container)
402
+ content_container.mount(sidebar)
403
+
404
+ chat_area_container.mount(chat_history)
405
+ chat_area_container.mount(agent_status_display)
406
+ chat_area_container.mount(chat_input_container)
407
+
408
+ self.call_after_refresh(self._focus_chat_input)
409
+
410
+ def _focus_chat_input(self) -> None:
411
+ if len(self.screen_stack) > 1 or self.show_splash:
412
+ return
413
+
414
+ if not self.is_mounted:
415
+ return
416
+
417
+ try:
418
+ chat_input = self.query_one("#chat_input", ChatTextArea)
419
+ chat_input.show_vertical_scrollbar = False
420
+ chat_input.show_horizontal_scrollbar = False
421
+ chat_input.focus()
422
+ except (ValueError, Exception):
423
+ self.call_after_refresh(self._focus_chat_input)
424
+
425
+ def _focus_agents_tree(self) -> None:
426
+ if len(self.screen_stack) > 1 or self.show_splash:
427
+ return
428
+
429
+ if not self.is_mounted:
430
+ return
431
+
432
+ try:
433
+ agents_tree = self.query_one("#agents_tree", Tree)
434
+ agents_tree.focus()
435
+
436
+ if agents_tree.root.children:
437
+ first_node = agents_tree.root.children[0]
438
+ agents_tree.select_node(first_node)
439
+ except (ValueError, Exception):
440
+ self.call_after_refresh(self._focus_agents_tree)
441
+
442
+ def on_mount(self) -> None:
443
+ self.title = "strix"
444
+
445
+ self.set_timer(4.5, self._hide_splash_screen)
446
+
447
+ def _hide_splash_screen(self) -> None:
448
+ self.show_splash = False
449
+
450
+ self._start_scan_thread()
451
+
452
+ self.set_interval(0.5, self._update_ui_from_tracer)
453
+
454
+ def _update_ui_from_tracer(self) -> None:
455
+ if self.show_splash:
456
+ return
457
+
458
+ if len(self.screen_stack) > 1:
459
+ return
460
+
461
+ if not self.is_mounted:
462
+ return
463
+
464
+ try:
465
+ chat_history = self.query_one("#chat_history", VerticalScroll)
466
+ agents_tree = self.query_one("#agents_tree", Tree)
467
+
468
+ if not self._is_widget_safe(chat_history) or not self._is_widget_safe(agents_tree):
469
+ return
470
+ except (ValueError, Exception):
471
+ return
472
+
473
+ agent_updates = False
474
+ for agent_id, agent_data in self.tracer.agents.items():
475
+ if agent_id not in self._displayed_agents:
476
+ self._add_agent_node(agent_data)
477
+ self._displayed_agents.add(agent_id)
478
+ agent_updates = True
479
+ elif self._update_agent_node(agent_id, agent_data):
480
+ agent_updates = True
481
+
482
+ if agent_updates:
483
+ self._expand_all_agent_nodes()
484
+
485
+ self._update_chat_view()
486
+
487
+ self._update_agent_status_display()
488
+
489
+ self._update_stats_display()
490
+
491
+ def _update_agent_node(self, agent_id: str, agent_data: dict[str, Any]) -> bool:
492
+ if agent_id not in self.agent_nodes:
493
+ return False
494
+
495
+ try:
496
+ agent_node = self.agent_nodes[agent_id]
497
+ agent_name_raw = agent_data.get("name", "Agent")
498
+ status = agent_data.get("status", "running")
499
+
500
+ status_indicators = {
501
+ "running": "🟢",
502
+ "waiting": "⏸️",
503
+ "completed": "✅",
504
+ "failed": "❌",
505
+ "stopped": "⏹️",
506
+ "stopping": "⏸️",
507
+ "llm_failed": "🔴",
508
+ }
509
+
510
+ 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)
519
+
520
+ if agent_node.label != agent_name:
521
+ agent_node.set_label(agent_name)
522
+ return True
523
+
524
+ except (KeyError, AttributeError, ValueError) as e:
525
+ import logging
526
+
527
+ logging.warning(f"Failed to update agent node label: {e}")
528
+
529
+ return False
530
+
531
+ def _update_chat_view(self) -> None:
532
+ if len(self.screen_stack) > 1 or self.show_splash:
533
+ return
534
+
535
+ if not self.is_mounted:
536
+ return
537
+
538
+ try:
539
+ chat_history = self.query_one("#chat_history", VerticalScroll)
540
+ except (ValueError, Exception):
541
+ return
542
+
543
+ if not self._is_widget_safe(chat_history):
544
+ return
545
+
546
+ try:
547
+ is_at_bottom = chat_history.scroll_y >= chat_history.max_scroll_y
548
+ except (AttributeError, ValueError):
549
+ is_at_bottom = True
550
+
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
568
+
569
+ chat_display = self.query_one("#chat_display", Static)
570
+ self._update_static_content_safe(chat_display, content)
571
+
572
+ chat_display.set_classes(css_class)
573
+
574
+ if is_at_bottom:
575
+ self.call_later(chat_history.scroll_end, animate=False)
576
+
577
+ def _get_chat_placeholder_content(
578
+ self, message: str, placeholder_class: str
579
+ ) -> tuple[str, str]:
580
+ self._displayed_events = [placeholder_class]
581
+ return message, f"chat-placeholder {placeholder_class}"
582
+
583
+ def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> str:
584
+ if not events:
585
+ return ""
586
+
587
+ content_lines = []
588
+ for event in events:
589
+ if event["type"] == "chat":
590
+ chat_content = self._render_chat_content(event["data"])
591
+ if chat_content:
592
+ content_lines.append(chat_content)
593
+ elif event["type"] == "tool":
594
+ tool_content = self._render_tool_content_simple(event["data"])
595
+ if tool_content:
596
+ content_lines.append(tool_content)
597
+
598
+ return "\n\n".join(content_lines)
599
+
600
+ def _update_agent_status_display(self) -> None:
601
+ try:
602
+ status_display = self.query_one("#agent_status_display", Horizontal)
603
+ status_text = self.query_one("#status_text", Static)
604
+ keymap_indicator = self.query_one("#keymap_indicator", Static)
605
+ except (ValueError, Exception):
606
+ return
607
+
608
+ widgets = [status_display, status_text, keymap_indicator]
609
+ if not all(self._is_widget_safe(w) for w in widgets):
610
+ return
611
+
612
+ if not self.selected_agent_id:
613
+ self._safe_widget_operation(status_display.add_class, "hidden")
614
+ return
615
+
616
+ try:
617
+ agent_data = self.tracer.agents[self.selected_agent_id]
618
+ status = agent_data.get("status", "running")
619
+
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:
663
+ self._safe_widget_operation(status_display.add_class, "hidden")
664
+
665
+ except (KeyError, Exception):
666
+ self._safe_widget_operation(status_display.add_class, "hidden")
667
+
668
+ def _update_stats_display(self) -> None:
669
+ try:
670
+ stats_display = self.query_one("#stats_display", Static)
671
+ except (ValueError, Exception):
672
+ return
673
+
674
+ if not self._is_widget_safe(stats_display):
675
+ return
676
+
677
+ stats_content = Text()
678
+
679
+ stats_text = build_live_stats_text(self.tracer)
680
+ if stats_text:
681
+ stats_content.append(stats_text)
682
+
683
+ from rich.panel import Panel
684
+
685
+ stats_panel = Panel(
686
+ stats_content,
687
+ title="📊 Live Stats",
688
+ title_align="left",
689
+ border_style="#22c55e",
690
+ padding=(0, 1),
691
+ )
692
+
693
+ self._safe_widget_operation(stats_display.update, stats_panel)
694
+
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
714
+ return
715
+
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()
722
+
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
726
+
727
+ dot_count = self._agent_dot_states[agent_id]
728
+ dots = "." * dot_count
729
+ return f"{verb}{dots}"
730
+
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
734
+
735
+ dot_count = self._agent_dot_states[agent_id]
736
+ dots = "." * dot_count
737
+
738
+ return f"Waiting{dots}"
739
+
740
+ def _start_dot_animation(self) -> None:
741
+ if self._dot_animation_timer is None:
742
+ self._dot_animation_timer = self.set_interval(0.6, self._animate_dots)
743
+
744
+ def _stop_dot_animation(self) -> None:
745
+ if self._dot_animation_timer is not None:
746
+ self._dot_animation_timer.stop()
747
+ self._dot_animation_timer = None
748
+
749
+ def _animate_dots(self) -> None:
750
+ has_active_agents = False
751
+
752
+ for agent_id, agent_data in self.tracer.agents.items():
753
+ status = agent_data.get("status", "running")
754
+ if status in ["running", "waiting"]:
755
+ 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"]:
766
+ self._update_agent_status_display()
767
+
768
+ if not has_active_agents:
769
+ 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]
775
+
776
+ def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]:
777
+ chat_events = [
778
+ {
779
+ "type": "chat",
780
+ "timestamp": msg["timestamp"],
781
+ "id": f"chat_{msg['message_id']}",
782
+ "data": msg,
783
+ }
784
+ for msg in self.tracer.chat_messages
785
+ if msg.get("agent_id") == agent_id
786
+ ]
787
+
788
+ tool_events = [
789
+ {
790
+ "type": "tool",
791
+ "timestamp": tool_data["timestamp"],
792
+ "id": f"tool_{exec_id}",
793
+ "data": tool_data,
794
+ }
795
+ for exec_id, tool_data in self.tracer.tool_executions.items()
796
+ if tool_data.get("agent_id") == agent_id
797
+ ]
798
+
799
+ events = chat_events + tool_events
800
+ events.sort(key=lambda e: (e["timestamp"], e["id"]))
801
+ return events
802
+
803
+ def watch_selected_agent_id(self, _agent_id: str | None) -> None:
804
+ if len(self.screen_stack) > 1 or self.show_splash:
805
+ return
806
+
807
+ if not self.is_mounted:
808
+ return
809
+
810
+ self._displayed_events.clear()
811
+
812
+ self.call_later(self._update_chat_view)
813
+ self._update_agent_status_display()
814
+
815
+ def _start_scan_thread(self) -> None:
816
+ def scan_target() -> None:
817
+ try:
818
+ loop = asyncio.new_event_loop()
819
+ asyncio.set_event_loop(loop)
820
+
821
+ try:
822
+ agent = StrixAgent(self.agent_config)
823
+
824
+ if not self._scan_stop_event.is_set():
825
+ loop.run_until_complete(agent.execute_scan(self.scan_config))
826
+
827
+ except (KeyboardInterrupt, asyncio.CancelledError):
828
+ logging.info("Scan interrupted by user")
829
+ except (ConnectionError, TimeoutError):
830
+ logging.exception("Network error during scan")
831
+ except RuntimeError:
832
+ logging.exception("Runtime error during scan")
833
+ except Exception:
834
+ logging.exception("Unexpected error during scan")
835
+ finally:
836
+ loop.close()
837
+ self._scan_completed.set()
838
+
839
+ except Exception:
840
+ logging.exception("Error setting up scan thread")
841
+ self._scan_completed.set()
842
+
843
+ self._scan_thread = threading.Thread(target=scan_target, daemon=True)
844
+ self._scan_thread.start()
845
+
846
+ def _add_agent_node(self, agent_data: dict[str, Any]) -> None:
847
+ if len(self.screen_stack) > 1 or self.show_splash:
848
+ return
849
+
850
+ if not self.is_mounted:
851
+ return
852
+
853
+ agent_id = agent_data["id"]
854
+ parent_id = agent_data.get("parent_id")
855
+ status = agent_data.get("status", "running")
856
+
857
+ try:
858
+ agents_tree = self.query_one("#agents_tree", Tree)
859
+ except (ValueError, Exception):
860
+ return
861
+
862
+ agent_name_raw = agent_data.get("name", "Agent")
863
+
864
+ status_indicators = {
865
+ "running": "🟢",
866
+ "waiting": "🟡",
867
+ "completed": "✅",
868
+ "failed": "❌",
869
+ "stopped": "⏹️",
870
+ "stopping": "⏸️",
871
+ }
872
+
873
+ 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)
878
+
879
+ try:
880
+ if parent_id and parent_id in self.agent_nodes:
881
+ parent_node = self.agent_nodes[parent_id]
882
+ agent_node = parent_node.add(
883
+ agent_name,
884
+ data={"agent_id": agent_id},
885
+ )
886
+ parent_node.allow_expand = True
887
+ else:
888
+ agent_node = agents_tree.root.add(
889
+ agent_name,
890
+ data={"agent_id": agent_id},
891
+ )
892
+
893
+ agent_node.allow_expand = False
894
+ agent_node.expand()
895
+ self.agent_nodes[agent_id] = agent_node
896
+
897
+ if len(self.agent_nodes) == 1:
898
+ agents_tree.select_node(agent_node)
899
+ self.selected_agent_id = agent_id
900
+
901
+ self._reorganize_orphaned_agents(agent_id)
902
+ except (AttributeError, ValueError, RuntimeError) as e:
903
+ import logging
904
+
905
+ logging.warning(f"Failed to add agent node {agent_id}: {e}")
906
+
907
+ def _expand_all_agent_nodes(self) -> None:
908
+ if len(self.screen_stack) > 1 or self.show_splash:
909
+ return
910
+
911
+ if not self.is_mounted:
912
+ return
913
+
914
+ try:
915
+ agents_tree = self.query_one("#agents_tree", Tree)
916
+ self._expand_node_recursively(agents_tree.root)
917
+ except (ValueError, Exception):
918
+ logging.debug("Tree not ready for expanding nodes")
919
+
920
+ def _expand_node_recursively(self, node: TreeNode) -> None:
921
+ if not node.is_expanded:
922
+ node.expand()
923
+ for child in node.children:
924
+ self._expand_node_recursively(child)
925
+
926
+ def _copy_node_under(self, node_to_copy: TreeNode, new_parent: TreeNode) -> None:
927
+ agent_id = node_to_copy.data["agent_id"]
928
+ agent_data = self.tracer.agents.get(agent_id, {})
929
+ agent_name_raw = agent_data.get("name", "Agent")
930
+ status = agent_data.get("status", "running")
931
+
932
+ status_indicators = {
933
+ "running": "🟢",
934
+ "waiting": "🟡",
935
+ "completed": "✅",
936
+ "failed": "❌",
937
+ "stopped": "⏹️",
938
+ "stopping": "⏸️",
939
+ }
940
+
941
+ status_icon = status_indicators.get(status, "🔵")
942
+ agent_name = f"{status_icon} {escape_markup(agent_name_raw)}"
943
+
944
+ new_node = new_parent.add(
945
+ agent_name,
946
+ data=node_to_copy.data,
947
+ )
948
+ new_node.allow_expand = node_to_copy.allow_expand
949
+
950
+ self.agent_nodes[agent_id] = new_node
951
+
952
+ for child in node_to_copy.children:
953
+ self._copy_node_under(child, new_node)
954
+
955
+ if node_to_copy.is_expanded:
956
+ new_node.expand()
957
+
958
+ def _reorganize_orphaned_agents(self, new_parent_id: str) -> None:
959
+ agents_to_move = []
960
+
961
+ for agent_id, agent_data in self.tracer.agents.items():
962
+ if (
963
+ agent_data.get("parent_id") == new_parent_id
964
+ and agent_id in self.agent_nodes
965
+ and agent_id != new_parent_id
966
+ ):
967
+ agents_to_move.append(agent_id)
968
+
969
+ if not agents_to_move:
970
+ return
971
+
972
+ parent_node = self.agent_nodes[new_parent_id]
973
+
974
+ for child_agent_id in agents_to_move:
975
+ if child_agent_id in self.agent_nodes:
976
+ old_node = self.agent_nodes[child_agent_id]
977
+
978
+ if old_node.parent is parent_node:
979
+ continue
980
+
981
+ self._copy_node_under(old_node, parent_node)
982
+
983
+ old_node.remove()
984
+
985
+ parent_node.allow_expand = True
986
+ self._expand_all_agent_nodes()
987
+
988
+ def _render_chat_content(self, msg_data: dict[str, Any]) -> str:
989
+ role = msg_data.get("role")
990
+ content = escape_markup(msg_data.get("content", ""))
991
+
992
+ if not content:
993
+ return ""
994
+
995
+ if role == "user":
996
+ from strix.interface.tool_components.user_message_renderer import UserMessageRenderer
997
+
998
+ return UserMessageRenderer.render_simple(content)
999
+ return content
1000
+
1001
+ def _render_tool_content_simple(self, tool_data: dict[str, Any]) -> str:
1002
+ tool_name = tool_data.get("tool_name", "Unknown Tool")
1003
+ args = tool_data.get("args", {})
1004
+ status = tool_data.get("status", "unknown")
1005
+ result = tool_data.get("result")
1006
+
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
+ from strix.interface.tool_components.registry import get_tool_renderer
1027
+
1028
+ renderer = get_tool_renderer(tool_name)
1029
+
1030
+ if renderer:
1031
+ 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]")
1049
+
1050
+ lines = [f"→ Using tool [bold blue]{escape_markup(tool_name)}[/] {status_icon}"]
1051
+
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)}")
1058
+
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)}")
1064
+
1065
+ content = "\n".join(lines)
1066
+
1067
+ lines = content.split("\n")
1068
+ bordered_lines = [f"[{color}]▍[/{color}] {line}" for line in lines]
1069
+ return "\n".join(bordered_lines)
1070
+
1071
+ @on(Tree.NodeHighlighted) # type: ignore[misc]
1072
+ def handle_tree_highlight(self, event: Tree.NodeHighlighted) -> None:
1073
+ if len(self.screen_stack) > 1 or self.show_splash:
1074
+ return
1075
+
1076
+ if not self.is_mounted:
1077
+ return
1078
+
1079
+ node = event.node
1080
+
1081
+ try:
1082
+ agents_tree = self.query_one("#agents_tree", Tree)
1083
+ except (ValueError, Exception):
1084
+ return
1085
+
1086
+ if self.focused == agents_tree and node.data:
1087
+ agent_id = node.data.get("agent_id")
1088
+ if agent_id:
1089
+ self.selected_agent_id = agent_id
1090
+
1091
+ def _send_user_message(self, message: str) -> None:
1092
+ if not self.selected_agent_id:
1093
+ return
1094
+
1095
+ if self.tracer:
1096
+ self.tracer.log_chat_message(
1097
+ content=message,
1098
+ role="user",
1099
+ agent_id=self.selected_agent_id,
1100
+ )
1101
+
1102
+ try:
1103
+ from strix.tools.agents_graph.agents_graph_actions import send_user_message_to_agent
1104
+
1105
+ send_user_message_to_agent(self.selected_agent_id, message)
1106
+
1107
+ except (ImportError, AttributeError) as e:
1108
+ import logging
1109
+
1110
+ logging.warning(f"Failed to send message to agent {self.selected_agent_id}: {e}")
1111
+
1112
+ self._displayed_events.clear()
1113
+ self._update_chat_view()
1114
+
1115
+ self.call_after_refresh(self._focus_chat_input)
1116
+
1117
+ def _get_agent_name(self, agent_id: str) -> str:
1118
+ try:
1119
+ if self.tracer and agent_id in self.tracer.agents:
1120
+ agent_name = self.tracer.agents[agent_id].get("name")
1121
+ if isinstance(agent_name, str):
1122
+ return agent_name
1123
+ except (KeyError, AttributeError) as e:
1124
+ logging.warning(f"Could not retrieve agent name for {agent_id}: {e}")
1125
+ return "Unknown Agent"
1126
+
1127
+ def action_toggle_help(self) -> None:
1128
+ if self.show_splash or not self.is_mounted:
1129
+ return
1130
+
1131
+ try:
1132
+ self.query_one("#main_container")
1133
+ except (ValueError, Exception):
1134
+ return
1135
+
1136
+ if isinstance(self.screen, HelpScreen):
1137
+ self.pop_screen()
1138
+ return
1139
+
1140
+ if len(self.screen_stack) > 1:
1141
+ return
1142
+
1143
+ self.push_screen(HelpScreen())
1144
+
1145
+ def action_request_quit(self) -> None:
1146
+ if self.show_splash or not self.is_mounted:
1147
+ self.action_custom_quit()
1148
+ return
1149
+
1150
+ if len(self.screen_stack) > 1:
1151
+ return
1152
+
1153
+ try:
1154
+ self.query_one("#main_container")
1155
+ except (ValueError, Exception):
1156
+ self.action_custom_quit()
1157
+ return
1158
+
1159
+ self.push_screen(QuitScreen())
1160
+
1161
+ 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
+ ):
1168
+ return
1169
+
1170
+ agent_name, should_stop = self._validate_agent_for_stopping()
1171
+ if not should_stop:
1172
+ return
1173
+
1174
+ try:
1175
+ self.query_one("#main_container")
1176
+ except (ValueError, Exception):
1177
+ return
1178
+
1179
+ self.push_screen(StopAgentScreen(agent_name, self.selected_agent_id))
1180
+
1181
+ def _validate_agent_for_stopping(self) -> tuple[str, bool]:
1182
+ agent_name = "Unknown Agent"
1183
+
1184
+ try:
1185
+ if self.tracer and self.selected_agent_id in self.tracer.agents:
1186
+ agent_data = self.tracer.agents[self.selected_agent_id]
1187
+ agent_name = agent_data.get("name", "Unknown Agent")
1188
+
1189
+ agent_status = agent_data.get("status", "running")
1190
+ if agent_status not in ["running"]:
1191
+ return agent_name, False
1192
+
1193
+ agent_events = self._gather_agent_events(self.selected_agent_id)
1194
+ if not agent_events:
1195
+ return agent_name, False
1196
+
1197
+ return agent_name, True
1198
+
1199
+ except (KeyError, AttributeError, ValueError) as e:
1200
+ import logging
1201
+
1202
+ logging.warning(f"Failed to gather agent events: {e}")
1203
+
1204
+ return agent_name, False
1205
+
1206
+ def action_confirm_stop_agent(self, agent_id: str) -> None:
1207
+ self.pop_screen()
1208
+
1209
+ try:
1210
+ from strix.tools.agents_graph.agents_graph_actions import stop_agent
1211
+
1212
+ result = stop_agent(agent_id)
1213
+
1214
+ import logging
1215
+
1216
+ if result.get("success"):
1217
+ logging.info(f"Stop request sent to agent: {result.get('message', 'Unknown')}")
1218
+ else:
1219
+ logging.warning(f"Failed to stop agent: {result.get('error', 'Unknown error')}")
1220
+
1221
+ except Exception:
1222
+ import logging
1223
+
1224
+ logging.exception(f"Failed to stop agent {agent_id}")
1225
+
1226
+ 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
+ if self._scan_thread and self._scan_thread.is_alive():
1231
+ self._scan_stop_event.set()
1232
+
1233
+ self._scan_thread.join(timeout=1.0)
1234
+
1235
+ self.tracer.cleanup()
1236
+
1237
+ self.exit()
1238
+
1239
+ def _is_widget_safe(self, widget: Any) -> bool:
1240
+ try:
1241
+ _ = widget.screen
1242
+ except (AttributeError, ValueError, Exception):
1243
+ return False
1244
+ else:
1245
+ return bool(widget.is_mounted)
1246
+
1247
+ def _safe_widget_operation(
1248
+ self, operation: Callable[..., Any], *args: Any, **kwargs: Any
1249
+ ) -> bool:
1250
+ try:
1251
+ operation(*args, **kwargs)
1252
+ except (AttributeError, ValueError, Exception):
1253
+ return False
1254
+ else:
1255
+ return True
1256
+
1257
+ def _update_static_content_safe(self, widget: Static, content: str) -> None:
1258
+ 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
1266
+
1267
+ plain_text = re.sub(r"\[.*?\]", "", content)
1268
+ widget.update(plain_text)
1269
+
1270
+
1271
+ async def run_tui(args: argparse.Namespace) -> None:
1272
+ """Run strix in interactive TUI mode with textual."""
1273
+ app = StrixTUIApp(args)
1274
+ await app.run_async()