strix-agent 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- strix/__init__.py +0 -0
- strix/agents/StrixAgent/__init__.py +4 -0
- strix/agents/StrixAgent/strix_agent.py +60 -0
- strix/agents/StrixAgent/system_prompt.jinja +504 -0
- strix/agents/__init__.py +10 -0
- strix/agents/base_agent.py +394 -0
- strix/agents/state.py +139 -0
- strix/cli/__init__.py +4 -0
- strix/cli/app.py +1124 -0
- strix/cli/assets/cli.tcss +680 -0
- strix/cli/main.py +542 -0
- strix/cli/tool_components/__init__.py +39 -0
- strix/cli/tool_components/agents_graph_renderer.py +129 -0
- strix/cli/tool_components/base_renderer.py +61 -0
- strix/cli/tool_components/browser_renderer.py +107 -0
- strix/cli/tool_components/file_edit_renderer.py +95 -0
- strix/cli/tool_components/finish_renderer.py +32 -0
- strix/cli/tool_components/notes_renderer.py +108 -0
- strix/cli/tool_components/proxy_renderer.py +255 -0
- strix/cli/tool_components/python_renderer.py +34 -0
- strix/cli/tool_components/registry.py +72 -0
- strix/cli/tool_components/reporting_renderer.py +53 -0
- strix/cli/tool_components/scan_info_renderer.py +58 -0
- strix/cli/tool_components/terminal_renderer.py +99 -0
- strix/cli/tool_components/thinking_renderer.py +29 -0
- strix/cli/tool_components/user_message_renderer.py +43 -0
- strix/cli/tool_components/web_search_renderer.py +28 -0
- strix/cli/tracer.py +308 -0
- strix/llm/__init__.py +14 -0
- strix/llm/config.py +19 -0
- strix/llm/llm.py +310 -0
- strix/llm/memory_compressor.py +206 -0
- strix/llm/request_queue.py +63 -0
- strix/llm/utils.py +84 -0
- strix/prompts/__init__.py +113 -0
- strix/prompts/coordination/root_agent.jinja +41 -0
- strix/prompts/vulnerabilities/authentication_jwt.jinja +129 -0
- strix/prompts/vulnerabilities/business_logic.jinja +143 -0
- strix/prompts/vulnerabilities/csrf.jinja +168 -0
- strix/prompts/vulnerabilities/idor.jinja +164 -0
- strix/prompts/vulnerabilities/race_conditions.jinja +194 -0
- strix/prompts/vulnerabilities/rce.jinja +222 -0
- strix/prompts/vulnerabilities/sql_injection.jinja +216 -0
- strix/prompts/vulnerabilities/ssrf.jinja +168 -0
- strix/prompts/vulnerabilities/xss.jinja +221 -0
- strix/prompts/vulnerabilities/xxe.jinja +276 -0
- strix/runtime/__init__.py +19 -0
- strix/runtime/docker_runtime.py +298 -0
- strix/runtime/runtime.py +25 -0
- strix/runtime/tool_server.py +97 -0
- strix/tools/__init__.py +64 -0
- strix/tools/agents_graph/__init__.py +16 -0
- strix/tools/agents_graph/agents_graph_actions.py +610 -0
- strix/tools/agents_graph/agents_graph_actions_schema.xml +223 -0
- strix/tools/argument_parser.py +120 -0
- strix/tools/browser/__init__.py +4 -0
- strix/tools/browser/browser_actions.py +236 -0
- strix/tools/browser/browser_actions_schema.xml +183 -0
- strix/tools/browser/browser_instance.py +533 -0
- strix/tools/browser/tab_manager.py +342 -0
- strix/tools/executor.py +302 -0
- strix/tools/file_edit/__init__.py +4 -0
- strix/tools/file_edit/file_edit_actions.py +141 -0
- strix/tools/file_edit/file_edit_actions_schema.xml +128 -0
- strix/tools/finish/__init__.py +4 -0
- strix/tools/finish/finish_actions.py +167 -0
- strix/tools/finish/finish_actions_schema.xml +45 -0
- strix/tools/notes/__init__.py +14 -0
- strix/tools/notes/notes_actions.py +191 -0
- strix/tools/notes/notes_actions_schema.xml +150 -0
- strix/tools/proxy/__init__.py +20 -0
- strix/tools/proxy/proxy_actions.py +101 -0
- strix/tools/proxy/proxy_actions_schema.xml +267 -0
- strix/tools/proxy/proxy_manager.py +785 -0
- strix/tools/python/__init__.py +4 -0
- strix/tools/python/python_actions.py +47 -0
- strix/tools/python/python_actions_schema.xml +131 -0
- strix/tools/python/python_instance.py +172 -0
- strix/tools/python/python_manager.py +131 -0
- strix/tools/registry.py +196 -0
- strix/tools/reporting/__init__.py +6 -0
- strix/tools/reporting/reporting_actions.py +63 -0
- strix/tools/reporting/reporting_actions_schema.xml +30 -0
- strix/tools/terminal/__init__.py +4 -0
- strix/tools/terminal/terminal_actions.py +53 -0
- strix/tools/terminal/terminal_actions_schema.xml +114 -0
- strix/tools/terminal/terminal_instance.py +231 -0
- strix/tools/terminal/terminal_manager.py +191 -0
- strix/tools/thinking/__init__.py +4 -0
- strix/tools/thinking/thinking_actions.py +18 -0
- strix/tools/thinking/thinking_actions_schema.xml +52 -0
- strix/tools/web_search/__init__.py +4 -0
- strix/tools/web_search/web_search_actions.py +80 -0
- strix/tools/web_search/web_search_actions_schema.xml +83 -0
- strix_agent-0.1.1.dist-info/LICENSE +201 -0
- strix_agent-0.1.1.dist-info/METADATA +200 -0
- strix_agent-0.1.1.dist-info/RECORD +99 -0
- strix_agent-0.1.1.dist-info/WHEEL +4 -0
- strix_agent-0.1.1.dist-info/entry_points.txt +3 -0
strix/cli/app.py
ADDED
@@ -0,0 +1,1124 @@
|
|
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 typing import Any, ClassVar
|
11
|
+
|
12
|
+
from textual import events, on
|
13
|
+
from textual.app import App, ComposeResult
|
14
|
+
from textual.binding import Binding
|
15
|
+
from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
|
16
|
+
from textual.reactive import reactive
|
17
|
+
from textual.screen import ModalScreen
|
18
|
+
from textual.widgets import Button, Label, Static, TextArea, Tree
|
19
|
+
from textual.widgets.tree import TreeNode
|
20
|
+
|
21
|
+
from strix.agents.StrixAgent import StrixAgent
|
22
|
+
from strix.cli.tracer import Tracer, set_global_tracer
|
23
|
+
from strix.llm.config import LLMConfig
|
24
|
+
|
25
|
+
|
26
|
+
def escape_markup(text: str) -> str:
|
27
|
+
return text.replace("[", "\\[").replace("]", "\\]")
|
28
|
+
|
29
|
+
|
30
|
+
class ChatTextArea(TextArea): # type: ignore[misc]
|
31
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
32
|
+
super().__init__(*args, **kwargs)
|
33
|
+
self._app_reference: StrixCLIApp | None = None
|
34
|
+
|
35
|
+
def set_app_reference(self, app: "StrixCLIApp") -> None:
|
36
|
+
self._app_reference = app
|
37
|
+
|
38
|
+
def _on_key(self, event: events.Key) -> None:
|
39
|
+
if event.key == "enter" and self._app_reference:
|
40
|
+
text_content = str(self.text) # type: ignore[has-type]
|
41
|
+
message = text_content.strip()
|
42
|
+
if message:
|
43
|
+
self.text = ""
|
44
|
+
|
45
|
+
self._app_reference._send_user_message(message)
|
46
|
+
|
47
|
+
event.prevent_default()
|
48
|
+
return
|
49
|
+
|
50
|
+
super()._on_key(event)
|
51
|
+
|
52
|
+
|
53
|
+
class SplashScreen(Static): # type: ignore[misc]
|
54
|
+
def compose(self) -> ComposeResult:
|
55
|
+
ascii_art = r"""
|
56
|
+
[bright_green]
|
57
|
+
⠰⢿⣿⡶⢤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡤⢶⣿⡿⠆
|
58
|
+
⠀⠀⢻⣿⠀⠈⠓⠤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⠚⠁⠀⣿⡟⠀⠀
|
59
|
+
⠀⠀⠠⣻⡆⠀⠀⠀⠠⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⠄⠀⠀⠀⢰⣟⠄⠀⠀
|
60
|
+
███████╗████████╗██████╗ ██╗██╗ ██╗ ⠀⠀⠀⢼⣷⠀⠀⠀⠀⠀⠙⠛⠶⣶⣶⣶⣶⠶⠛⠋⠀⠀⠀⠀⠀⣾⡧⠀⠀⠀
|
61
|
+
██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝ ⠀⠀⢀⣾⠃⢳⣧⡀⠀⠠⡀⢀⠀⠀⠉⠉⠀⠀⡀⢀⠄⠀⢀⣼⡞⠘⣷⡀⠀⠀
|
62
|
+
███████╗ ██║ ██████╔╝██║ ╚███╔╝ ⠀⠀⡾⠃⠀⠲⡾⢿⣓⣢⡀⠻⢷⣄⡀⢀⣠⡾⠟⢀⣔⣚⡿⢷⠖⠀⠘⢷⠀⠀
|
63
|
+
╚════██║ ██║ ██╔══██╗██║ ██╔██╗ ⠀⢠⠃⣰⠁⠀⠐⣿⢫⣿⣿⣆⠀⢈⡻⢟⣉⠀⣰⣿⣿⡝⣿⠂⠀⠈⣆⠘⡄⠀
|
64
|
+
███████║ ██║ ██║ ██║██║██╔╝ ██╗ ⠀⠀⠀⣽⠀⠀⠀⠘⣄⠙⣋⣼⠆⠈⢛⡛⠁⠰⣧⣙⠋⣠⠃⠀⠀⠀⣿⠀⠀⠀
|
65
|
+
╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ⠀⠀⠀⢻⡀⠀⠀⠀⠀⠀⠉⠀⠀⠀⣾⢱⠀⠀⠀⠉⠀⠀⠀⠀⠀⢀⡟⠀⠀⠀
|
66
|
+
⠀⠀⠀⠈⠻⣆⡀⠀⠀⠀⢀⡀⠀⠀⢿⡸⠀⠀⢀⡀⠀⠀⠀⢀⣰⠟⠃⠀⠀⠀
|
67
|
+
⠀⠀⠀⠀⠀⠉⠉⠂⠀⠀⠉⠛⢦⡀⠸⠇⢀⡴⠛⠉⠀⠀⠐⠉⠉⠀⠀⠀⠀⠀
|
68
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠶⠶⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
69
|
+
[/bright_green]
|
70
|
+
|
71
|
+
[bright_green]Starting Strix Cybersecurity Agent...[/bright_green]
|
72
|
+
"""
|
73
|
+
yield Static(ascii_art, id="splash_content")
|
74
|
+
|
75
|
+
|
76
|
+
class HelpScreen(ModalScreen): # type: ignore[misc]
|
77
|
+
def compose(self) -> ComposeResult:
|
78
|
+
yield Grid(
|
79
|
+
Label("🦉 Strix Help", id="help_title"),
|
80
|
+
Label(
|
81
|
+
"F1 Help\nCtrl+Q/C Quit\nESC Stop Agent\n"
|
82
|
+
"Enter Send message to agent\nTab Switch panels\n↑/↓ Navigate tree",
|
83
|
+
id="help_content",
|
84
|
+
),
|
85
|
+
id="dialog",
|
86
|
+
)
|
87
|
+
|
88
|
+
def on_key(self, _event: events.Key) -> None:
|
89
|
+
self.app.pop_screen()
|
90
|
+
|
91
|
+
|
92
|
+
class StopAgentScreen(ModalScreen): # type: ignore[misc]
|
93
|
+
def __init__(self, agent_name: str, agent_id: str):
|
94
|
+
super().__init__()
|
95
|
+
self.agent_name = agent_name
|
96
|
+
self.agent_id = agent_id
|
97
|
+
|
98
|
+
def compose(self) -> ComposeResult:
|
99
|
+
yield Grid(
|
100
|
+
Label(f"🛑 Stop '{self.agent_name}'?", id="stop_agent_title"),
|
101
|
+
Grid(
|
102
|
+
Button("Yes", variant="error", id="stop_agent"),
|
103
|
+
Button("No", variant="default", id="cancel_stop"),
|
104
|
+
id="stop_agent_buttons",
|
105
|
+
),
|
106
|
+
id="stop_agent_dialog",
|
107
|
+
)
|
108
|
+
|
109
|
+
def on_mount(self) -> None:
|
110
|
+
cancel_button = self.query_one("#cancel_stop", Button)
|
111
|
+
cancel_button.focus()
|
112
|
+
|
113
|
+
def on_key(self, event: events.Key) -> None:
|
114
|
+
if event.key in ("left", "right", "up", "down"):
|
115
|
+
focused = self.focused
|
116
|
+
|
117
|
+
if focused and focused.id == "stop_agent":
|
118
|
+
cancel_button = self.query_one("#cancel_stop", Button)
|
119
|
+
cancel_button.focus()
|
120
|
+
else:
|
121
|
+
stop_button = self.query_one("#stop_agent", Button)
|
122
|
+
stop_button.focus()
|
123
|
+
|
124
|
+
event.prevent_default()
|
125
|
+
elif event.key == "enter":
|
126
|
+
focused = self.focused
|
127
|
+
if focused and isinstance(focused, Button):
|
128
|
+
focused.press()
|
129
|
+
event.prevent_default()
|
130
|
+
elif event.key == "escape":
|
131
|
+
self.app.pop_screen()
|
132
|
+
event.prevent_default()
|
133
|
+
|
134
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
135
|
+
if event.button.id == "stop_agent":
|
136
|
+
self.app.action_confirm_stop_agent(self.agent_id)
|
137
|
+
else:
|
138
|
+
self.app.pop_screen()
|
139
|
+
|
140
|
+
|
141
|
+
class QuitScreen(ModalScreen): # type: ignore[misc]
|
142
|
+
def compose(self) -> ComposeResult:
|
143
|
+
yield Grid(
|
144
|
+
Label("🦉 Quit Strix? ", id="quit_title"),
|
145
|
+
Grid(
|
146
|
+
Button("Yes", variant="error", id="quit"),
|
147
|
+
Button("No", variant="default", id="cancel"),
|
148
|
+
id="quit_buttons",
|
149
|
+
),
|
150
|
+
id="quit_dialog",
|
151
|
+
)
|
152
|
+
|
153
|
+
def on_mount(self) -> None:
|
154
|
+
cancel_button = self.query_one("#cancel", Button)
|
155
|
+
cancel_button.focus()
|
156
|
+
|
157
|
+
def on_key(self, event: events.Key) -> None:
|
158
|
+
if event.key in ("left", "right", "up", "down"):
|
159
|
+
focused = self.focused
|
160
|
+
|
161
|
+
if focused and focused.id == "quit":
|
162
|
+
cancel_button = self.query_one("#cancel", Button)
|
163
|
+
cancel_button.focus()
|
164
|
+
else:
|
165
|
+
quit_button = self.query_one("#quit", Button)
|
166
|
+
quit_button.focus()
|
167
|
+
|
168
|
+
event.prevent_default()
|
169
|
+
elif event.key == "enter":
|
170
|
+
focused = self.focused
|
171
|
+
if focused and isinstance(focused, Button):
|
172
|
+
focused.press()
|
173
|
+
event.prevent_default()
|
174
|
+
elif event.key == "escape":
|
175
|
+
self.app.pop_screen()
|
176
|
+
event.prevent_default()
|
177
|
+
|
178
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
179
|
+
if event.button.id == "quit":
|
180
|
+
self.app.action_custom_quit()
|
181
|
+
else:
|
182
|
+
self.app.pop_screen()
|
183
|
+
|
184
|
+
|
185
|
+
class StrixCLIApp(App): # type: ignore[misc]
|
186
|
+
CSS_PATH = "assets/cli.tcss"
|
187
|
+
|
188
|
+
selected_agent_id: reactive[str | None] = reactive(default=None)
|
189
|
+
show_splash: reactive[bool] = reactive(default=True)
|
190
|
+
|
191
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
192
|
+
Binding("f1", "toggle_help", "Help", priority=True),
|
193
|
+
Binding("ctrl+q", "request_quit", "Quit", priority=True),
|
194
|
+
Binding("ctrl+c", "request_quit", "Quit", priority=True),
|
195
|
+
Binding("escape", "stop_selected_agent", "Stop Agent", priority=True),
|
196
|
+
]
|
197
|
+
|
198
|
+
def __init__(self, args: argparse.Namespace):
|
199
|
+
super().__init__()
|
200
|
+
self.args = args
|
201
|
+
self.scan_config = self._build_scan_config(args)
|
202
|
+
self.agent_config = self._build_agent_config(args)
|
203
|
+
|
204
|
+
self.tracer = Tracer(self.scan_config["run_name"])
|
205
|
+
self.tracer.set_scan_config(self.scan_config)
|
206
|
+
set_global_tracer(self.tracer)
|
207
|
+
|
208
|
+
self.agent_nodes: dict[str, TreeNode] = {}
|
209
|
+
|
210
|
+
self._displayed_agents: set[str] = set()
|
211
|
+
self._displayed_events: list[str] = []
|
212
|
+
|
213
|
+
self._scan_thread: threading.Thread | None = None
|
214
|
+
self._scan_stop_event = threading.Event()
|
215
|
+
self._scan_completed = threading.Event()
|
216
|
+
|
217
|
+
self._action_verbs = [
|
218
|
+
"Generating",
|
219
|
+
"Scanning",
|
220
|
+
"Analyzing",
|
221
|
+
"Probing",
|
222
|
+
"Hacking",
|
223
|
+
"Testing",
|
224
|
+
"Exploiting",
|
225
|
+
"Investigating",
|
226
|
+
]
|
227
|
+
self._agent_verbs: dict[str, str] = {} # agent_id -> current_verb
|
228
|
+
self._agent_verb_timers: dict[str, Any] = {} # agent_id -> timer
|
229
|
+
self._agent_dot_states: dict[str, int] = {} # agent_id -> dot_count (0-3)
|
230
|
+
self._dot_animation_timer: Any | None = None
|
231
|
+
|
232
|
+
self._setup_cleanup_handlers()
|
233
|
+
|
234
|
+
def _build_scan_config(self, args: argparse.Namespace) -> dict[str, Any]:
|
235
|
+
return {
|
236
|
+
"scan_id": args.run_name,
|
237
|
+
"scan_type": args.target_type,
|
238
|
+
"target": args.target_dict,
|
239
|
+
"user_instructions": args.instruction or "",
|
240
|
+
"run_name": args.run_name,
|
241
|
+
}
|
242
|
+
|
243
|
+
def _build_agent_config(self, args: argparse.Namespace) -> dict[str, Any]:
|
244
|
+
llm_config = LLMConfig()
|
245
|
+
|
246
|
+
config = {
|
247
|
+
"llm_config": llm_config,
|
248
|
+
"max_iterations": 200,
|
249
|
+
}
|
250
|
+
|
251
|
+
if args.target_type == "local_code" and "target_path" in args.target_dict:
|
252
|
+
config["local_source_path"] = args.target_dict["target_path"]
|
253
|
+
|
254
|
+
return config
|
255
|
+
|
256
|
+
def _setup_cleanup_handlers(self) -> None:
|
257
|
+
def cleanup_on_exit() -> None:
|
258
|
+
self.tracer.cleanup()
|
259
|
+
|
260
|
+
def signal_handler(_signum: int, _frame: Any) -> None:
|
261
|
+
self.tracer.cleanup()
|
262
|
+
sys.exit(0)
|
263
|
+
|
264
|
+
atexit.register(cleanup_on_exit)
|
265
|
+
signal.signal(signal.SIGINT, signal_handler)
|
266
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
267
|
+
if hasattr(signal, "SIGHUP"):
|
268
|
+
signal.signal(signal.SIGHUP, signal_handler)
|
269
|
+
|
270
|
+
def compose(self) -> ComposeResult:
|
271
|
+
if self.show_splash:
|
272
|
+
yield SplashScreen(id="splash_screen")
|
273
|
+
|
274
|
+
def watch_show_splash(self, show_splash: bool) -> None:
|
275
|
+
if not show_splash and self.is_mounted:
|
276
|
+
try:
|
277
|
+
splash = self.query_one("#splash_screen")
|
278
|
+
splash.remove()
|
279
|
+
except ValueError:
|
280
|
+
pass
|
281
|
+
|
282
|
+
main_container = Vertical(id="main_container")
|
283
|
+
|
284
|
+
self.mount(main_container)
|
285
|
+
|
286
|
+
content_container = Horizontal(id="content_container")
|
287
|
+
main_container.mount(content_container)
|
288
|
+
|
289
|
+
chat_area_container = Vertical(id="chat_area_container")
|
290
|
+
|
291
|
+
chat_display = Static("", id="chat_display")
|
292
|
+
chat_history = VerticalScroll(chat_display, id="chat_history")
|
293
|
+
chat_history.can_focus = True
|
294
|
+
|
295
|
+
status_text = Static("", id="status_text")
|
296
|
+
keymap_indicator = Static("", id="keymap_indicator")
|
297
|
+
|
298
|
+
agent_status_display = Horizontal(
|
299
|
+
status_text, keymap_indicator, id="agent_status_display", classes="hidden"
|
300
|
+
)
|
301
|
+
|
302
|
+
chat_prompt = Static("> ", id="chat_prompt")
|
303
|
+
chat_input = ChatTextArea(
|
304
|
+
"",
|
305
|
+
id="chat_input",
|
306
|
+
show_line_numbers=False,
|
307
|
+
)
|
308
|
+
chat_input.set_app_reference(self)
|
309
|
+
chat_input_container = Horizontal(chat_prompt, chat_input, id="chat_input_container")
|
310
|
+
|
311
|
+
agents_tree = Tree("🤖 Active Agents", id="agents_tree")
|
312
|
+
agents_tree.root.expand()
|
313
|
+
agents_tree.show_root = False
|
314
|
+
|
315
|
+
agents_tree.show_guide = True
|
316
|
+
agents_tree.guide_depth = 3
|
317
|
+
agents_tree.guide_style = "dashed"
|
318
|
+
|
319
|
+
content_container.mount(chat_area_container)
|
320
|
+
content_container.mount(agents_tree)
|
321
|
+
|
322
|
+
chat_area_container.mount(chat_history)
|
323
|
+
chat_area_container.mount(agent_status_display)
|
324
|
+
chat_area_container.mount(chat_input_container)
|
325
|
+
|
326
|
+
self.call_after_refresh(self._focus_chat_input)
|
327
|
+
|
328
|
+
def _focus_chat_input(self) -> None:
|
329
|
+
if len(self.screen_stack) > 1 or self.show_splash:
|
330
|
+
return
|
331
|
+
|
332
|
+
if not self.is_mounted:
|
333
|
+
return
|
334
|
+
|
335
|
+
try:
|
336
|
+
chat_input = self.query_one("#chat_input", ChatTextArea)
|
337
|
+
chat_input.show_vertical_scrollbar = False
|
338
|
+
chat_input.show_horizontal_scrollbar = False
|
339
|
+
chat_input.focus()
|
340
|
+
except (ValueError, Exception):
|
341
|
+
self.call_after_refresh(self._focus_chat_input)
|
342
|
+
|
343
|
+
def _focus_agents_tree(self) -> None:
|
344
|
+
if len(self.screen_stack) > 1 or self.show_splash:
|
345
|
+
return
|
346
|
+
|
347
|
+
if not self.is_mounted:
|
348
|
+
return
|
349
|
+
|
350
|
+
try:
|
351
|
+
agents_tree = self.query_one("#agents_tree", Tree)
|
352
|
+
agents_tree.focus()
|
353
|
+
|
354
|
+
if agents_tree.root.children:
|
355
|
+
first_node = agents_tree.root.children[0]
|
356
|
+
agents_tree.select_node(first_node)
|
357
|
+
except (ValueError, Exception):
|
358
|
+
self.call_after_refresh(self._focus_agents_tree)
|
359
|
+
|
360
|
+
def on_mount(self) -> None:
|
361
|
+
self.title = "strix"
|
362
|
+
|
363
|
+
self.set_timer(3.0, self._hide_splash_screen)
|
364
|
+
|
365
|
+
def _hide_splash_screen(self) -> None:
|
366
|
+
self.show_splash = False
|
367
|
+
|
368
|
+
self._start_scan_thread()
|
369
|
+
|
370
|
+
self.set_interval(0.5, self._update_ui_from_tracer)
|
371
|
+
|
372
|
+
def _update_ui_from_tracer(self) -> None:
|
373
|
+
if self.show_splash:
|
374
|
+
return
|
375
|
+
|
376
|
+
if len(self.screen_stack) > 1:
|
377
|
+
return
|
378
|
+
|
379
|
+
if not self.is_mounted:
|
380
|
+
return
|
381
|
+
|
382
|
+
try:
|
383
|
+
chat_history = self.query_one("#chat_history", VerticalScroll)
|
384
|
+
agents_tree = self.query_one("#agents_tree", Tree)
|
385
|
+
|
386
|
+
if not self._is_widget_safe(chat_history) or not self._is_widget_safe(agents_tree):
|
387
|
+
return
|
388
|
+
except (ValueError, Exception):
|
389
|
+
return
|
390
|
+
|
391
|
+
agent_updates = False
|
392
|
+
for agent_id, agent_data in self.tracer.agents.items():
|
393
|
+
if agent_id not in self._displayed_agents:
|
394
|
+
self._add_agent_node(agent_data)
|
395
|
+
self._displayed_agents.add(agent_id)
|
396
|
+
agent_updates = True
|
397
|
+
elif self._update_agent_node(agent_id, agent_data):
|
398
|
+
agent_updates = True
|
399
|
+
|
400
|
+
if agent_updates:
|
401
|
+
self._expand_all_agent_nodes()
|
402
|
+
|
403
|
+
self._update_chat_view()
|
404
|
+
|
405
|
+
self._update_agent_status_display()
|
406
|
+
|
407
|
+
def _update_agent_node(self, agent_id: str, agent_data: dict[str, Any]) -> bool:
|
408
|
+
if agent_id not in self.agent_nodes:
|
409
|
+
return False
|
410
|
+
|
411
|
+
try:
|
412
|
+
agent_node = self.agent_nodes[agent_id]
|
413
|
+
agent_name_raw = agent_data.get("name", "Agent")
|
414
|
+
status = agent_data.get("status", "running")
|
415
|
+
|
416
|
+
status_indicators = {
|
417
|
+
"running": "🟢",
|
418
|
+
"waiting": "⏸️",
|
419
|
+
"completed": "✅",
|
420
|
+
"failed": "❌",
|
421
|
+
"stopped": "⏹️",
|
422
|
+
"stopping": "⏸️",
|
423
|
+
}
|
424
|
+
|
425
|
+
status_icon = status_indicators.get(status, "🔵")
|
426
|
+
agent_name = f"{status_icon} {escape_markup(agent_name_raw)}"
|
427
|
+
|
428
|
+
if status == "running":
|
429
|
+
self._start_agent_verb_timer(agent_id)
|
430
|
+
elif status == "waiting":
|
431
|
+
self._stop_agent_verb_timer(agent_id)
|
432
|
+
else:
|
433
|
+
self._stop_agent_verb_timer(agent_id)
|
434
|
+
|
435
|
+
if agent_node.label != agent_name:
|
436
|
+
agent_node.set_label(agent_name)
|
437
|
+
return True
|
438
|
+
|
439
|
+
except (KeyError, AttributeError, ValueError) as e:
|
440
|
+
import logging
|
441
|
+
|
442
|
+
logging.warning(f"Failed to update agent node label: {e}")
|
443
|
+
|
444
|
+
return False
|
445
|
+
|
446
|
+
def _update_chat_view(self) -> None:
|
447
|
+
if len(self.screen_stack) > 1 or self.show_splash:
|
448
|
+
return
|
449
|
+
|
450
|
+
if not self.is_mounted:
|
451
|
+
return
|
452
|
+
|
453
|
+
try:
|
454
|
+
chat_history = self.query_one("#chat_history", VerticalScroll)
|
455
|
+
except (ValueError, Exception):
|
456
|
+
return
|
457
|
+
|
458
|
+
if not self._is_widget_safe(chat_history):
|
459
|
+
return
|
460
|
+
|
461
|
+
try:
|
462
|
+
is_at_bottom = chat_history.scroll_y >= chat_history.max_scroll_y
|
463
|
+
except (AttributeError, ValueError):
|
464
|
+
is_at_bottom = True
|
465
|
+
|
466
|
+
if not self.selected_agent_id:
|
467
|
+
content, css_class = self._get_chat_placeholder_content(
|
468
|
+
"Select an agent from the tree to see its activity.", "placeholder-no-agent"
|
469
|
+
)
|
470
|
+
else:
|
471
|
+
events = self._gather_agent_events(self.selected_agent_id)
|
472
|
+
if not events:
|
473
|
+
content, css_class = self._get_chat_placeholder_content(
|
474
|
+
"Starting agent...", "placeholder-no-activity"
|
475
|
+
)
|
476
|
+
else:
|
477
|
+
current_event_ids = [e["id"] for e in events]
|
478
|
+
if current_event_ids == self._displayed_events:
|
479
|
+
return
|
480
|
+
content = self._get_rendered_events_content(events)
|
481
|
+
css_class = "chat-content"
|
482
|
+
self._displayed_events = current_event_ids
|
483
|
+
|
484
|
+
chat_display = self.query_one("#chat_display", Static)
|
485
|
+
chat_display.update(content)
|
486
|
+
|
487
|
+
chat_display.set_classes(css_class)
|
488
|
+
|
489
|
+
if is_at_bottom:
|
490
|
+
self.call_later(chat_history.scroll_end, animate=False)
|
491
|
+
|
492
|
+
def _get_chat_placeholder_content(
|
493
|
+
self, message: str, placeholder_class: str
|
494
|
+
) -> tuple[str, str]:
|
495
|
+
self._displayed_events = [placeholder_class]
|
496
|
+
return message, f"chat-placeholder {placeholder_class}"
|
497
|
+
|
498
|
+
def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> str:
|
499
|
+
if not events:
|
500
|
+
return ""
|
501
|
+
|
502
|
+
content_lines = []
|
503
|
+
for event in events:
|
504
|
+
if event["type"] == "chat":
|
505
|
+
chat_content = self._render_chat_content(event["data"])
|
506
|
+
if chat_content:
|
507
|
+
content_lines.append(chat_content)
|
508
|
+
elif event["type"] == "tool":
|
509
|
+
tool_content = self._render_tool_content_simple(event["data"])
|
510
|
+
if tool_content:
|
511
|
+
content_lines.append(tool_content)
|
512
|
+
|
513
|
+
return "\n\n".join(content_lines)
|
514
|
+
|
515
|
+
def _update_agent_status_display(self) -> None:
|
516
|
+
try:
|
517
|
+
status_display = self.query_one("#agent_status_display", Horizontal)
|
518
|
+
status_text = self.query_one("#status_text", Static)
|
519
|
+
keymap_indicator = self.query_one("#keymap_indicator", Static)
|
520
|
+
except (ValueError, Exception):
|
521
|
+
return
|
522
|
+
|
523
|
+
widgets = [status_display, status_text, keymap_indicator]
|
524
|
+
if not all(self._is_widget_safe(w) for w in widgets):
|
525
|
+
return
|
526
|
+
|
527
|
+
if not self.selected_agent_id:
|
528
|
+
self._safe_widget_operation(status_display.add_class, "hidden")
|
529
|
+
return
|
530
|
+
|
531
|
+
try:
|
532
|
+
agent_data = self.tracer.agents[self.selected_agent_id]
|
533
|
+
status = agent_data.get("status", "running")
|
534
|
+
|
535
|
+
if status == "stopping":
|
536
|
+
self._safe_widget_operation(status_text.update, "Agent stopping...")
|
537
|
+
self._safe_widget_operation(keymap_indicator.update, "")
|
538
|
+
self._safe_widget_operation(status_display.remove_class, "hidden")
|
539
|
+
elif status == "stopped":
|
540
|
+
self._safe_widget_operation(status_text.update, "Agent stopped")
|
541
|
+
self._safe_widget_operation(keymap_indicator.update, "")
|
542
|
+
self._safe_widget_operation(status_display.remove_class, "hidden")
|
543
|
+
elif status == "completed":
|
544
|
+
self._safe_widget_operation(status_text.update, "Agent completed")
|
545
|
+
self._safe_widget_operation(keymap_indicator.update, "")
|
546
|
+
self._safe_widget_operation(status_display.remove_class, "hidden")
|
547
|
+
elif status == "waiting":
|
548
|
+
animated_text = self._get_animated_waiting_text(self.selected_agent_id)
|
549
|
+
self._safe_widget_operation(status_text.update, animated_text)
|
550
|
+
self._safe_widget_operation(
|
551
|
+
keymap_indicator.update, "[dim]Send message to resume[/dim]"
|
552
|
+
)
|
553
|
+
self._safe_widget_operation(status_display.remove_class, "hidden")
|
554
|
+
self._start_dot_animation()
|
555
|
+
elif status == "running":
|
556
|
+
current_verb = self._get_agent_verb(self.selected_agent_id)
|
557
|
+
animated_text = self._get_animated_verb_text(self.selected_agent_id, current_verb)
|
558
|
+
self._safe_widget_operation(status_text.update, animated_text)
|
559
|
+
self._safe_widget_operation(keymap_indicator.update, "[dim]ESC to stop agent[/dim]")
|
560
|
+
self._safe_widget_operation(status_display.remove_class, "hidden")
|
561
|
+
self._start_dot_animation()
|
562
|
+
else:
|
563
|
+
self._safe_widget_operation(status_display.add_class, "hidden")
|
564
|
+
|
565
|
+
except (KeyError, Exception):
|
566
|
+
self._safe_widget_operation(status_display.add_class, "hidden")
|
567
|
+
|
568
|
+
def _get_agent_verb(self, agent_id: str) -> str:
|
569
|
+
if agent_id not in self._agent_verbs:
|
570
|
+
self._agent_verbs[agent_id] = random.choice(self._action_verbs) # nosec B311 # noqa: S311
|
571
|
+
return self._agent_verbs[agent_id]
|
572
|
+
|
573
|
+
def _start_agent_verb_timer(self, agent_id: str) -> None:
|
574
|
+
if agent_id not in self._agent_verb_timers:
|
575
|
+
self._agent_verb_timers[agent_id] = self.set_interval(
|
576
|
+
30.0, lambda: self._change_agent_action_verb(agent_id)
|
577
|
+
)
|
578
|
+
|
579
|
+
def _stop_agent_verb_timer(self, agent_id: str) -> None:
|
580
|
+
if agent_id in self._agent_verb_timers:
|
581
|
+
self._agent_verb_timers[agent_id].stop()
|
582
|
+
del self._agent_verb_timers[agent_id]
|
583
|
+
|
584
|
+
def _change_agent_action_verb(self, agent_id: str) -> None:
|
585
|
+
if agent_id not in self._agent_verbs:
|
586
|
+
self._agent_verbs[agent_id] = random.choice(self._action_verbs) # nosec B311 # noqa: S311
|
587
|
+
return
|
588
|
+
|
589
|
+
current_verb = self._agent_verbs[agent_id]
|
590
|
+
available_verbs = [verb for verb in self._action_verbs if verb != current_verb]
|
591
|
+
self._agent_verbs[agent_id] = random.choice(available_verbs) # nosec B311 # noqa: S311
|
592
|
+
|
593
|
+
if self.selected_agent_id == agent_id:
|
594
|
+
self._update_agent_status_display()
|
595
|
+
|
596
|
+
def _get_animated_verb_text(self, agent_id: str, verb: str) -> str:
|
597
|
+
if agent_id not in self._agent_dot_states:
|
598
|
+
self._agent_dot_states[agent_id] = 0
|
599
|
+
|
600
|
+
dot_count = self._agent_dot_states[agent_id]
|
601
|
+
dots = "." * dot_count
|
602
|
+
return f"{verb}{dots}"
|
603
|
+
|
604
|
+
def _get_animated_waiting_text(self, agent_id: str) -> str:
|
605
|
+
if agent_id not in self._agent_dot_states:
|
606
|
+
self._agent_dot_states[agent_id] = 0
|
607
|
+
|
608
|
+
dot_count = self._agent_dot_states[agent_id]
|
609
|
+
dots = "." * dot_count
|
610
|
+
|
611
|
+
return f"Waiting{dots}"
|
612
|
+
|
613
|
+
def _start_dot_animation(self) -> None:
|
614
|
+
if self._dot_animation_timer is None:
|
615
|
+
self._dot_animation_timer = self.set_interval(0.6, self._animate_dots)
|
616
|
+
|
617
|
+
def _stop_dot_animation(self) -> None:
|
618
|
+
if self._dot_animation_timer is not None:
|
619
|
+
self._dot_animation_timer.stop()
|
620
|
+
self._dot_animation_timer = None
|
621
|
+
|
622
|
+
def _animate_dots(self) -> None:
|
623
|
+
has_active_agents = False
|
624
|
+
|
625
|
+
for agent_id, agent_data in self.tracer.agents.items():
|
626
|
+
status = agent_data.get("status", "running")
|
627
|
+
if status in ["running", "waiting"]:
|
628
|
+
has_active_agents = True
|
629
|
+
current_dots = self._agent_dot_states.get(agent_id, 0)
|
630
|
+
self._agent_dot_states[agent_id] = (current_dots + 1) % 4
|
631
|
+
|
632
|
+
if (
|
633
|
+
has_active_agents
|
634
|
+
and self.selected_agent_id
|
635
|
+
and self.selected_agent_id in self.tracer.agents
|
636
|
+
):
|
637
|
+
selected_status = self.tracer.agents[self.selected_agent_id].get("status", "running")
|
638
|
+
if selected_status in ["running", "waiting"]:
|
639
|
+
self._update_agent_status_display()
|
640
|
+
|
641
|
+
if not has_active_agents:
|
642
|
+
self._stop_dot_animation()
|
643
|
+
for agent_id in list(self._agent_dot_states.keys()):
|
644
|
+
if agent_id not in self.tracer.agents or self.tracer.agents[agent_id].get(
|
645
|
+
"status"
|
646
|
+
) not in ["running", "waiting"]:
|
647
|
+
del self._agent_dot_states[agent_id]
|
648
|
+
|
649
|
+
def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]:
|
650
|
+
chat_events = [
|
651
|
+
{
|
652
|
+
"type": "chat",
|
653
|
+
"timestamp": msg["timestamp"],
|
654
|
+
"id": f"chat_{msg['message_id']}",
|
655
|
+
"data": msg,
|
656
|
+
}
|
657
|
+
for msg in self.tracer.chat_messages
|
658
|
+
if msg.get("agent_id") == agent_id
|
659
|
+
]
|
660
|
+
|
661
|
+
tool_events = [
|
662
|
+
{
|
663
|
+
"type": "tool",
|
664
|
+
"timestamp": tool_data["timestamp"],
|
665
|
+
"id": f"tool_{exec_id}",
|
666
|
+
"data": tool_data,
|
667
|
+
}
|
668
|
+
for exec_id, tool_data in self.tracer.tool_executions.items()
|
669
|
+
if tool_data.get("agent_id") == agent_id
|
670
|
+
]
|
671
|
+
|
672
|
+
events = chat_events + tool_events
|
673
|
+
events.sort(key=lambda e: (e["timestamp"], e["id"]))
|
674
|
+
return events
|
675
|
+
|
676
|
+
def watch_selected_agent_id(self, _agent_id: str | None) -> None:
|
677
|
+
if len(self.screen_stack) > 1 or self.show_splash:
|
678
|
+
return
|
679
|
+
|
680
|
+
if not self.is_mounted:
|
681
|
+
return
|
682
|
+
|
683
|
+
self._displayed_events.clear()
|
684
|
+
|
685
|
+
self.call_later(self._update_chat_view)
|
686
|
+
self._update_agent_status_display()
|
687
|
+
|
688
|
+
def _start_scan_thread(self) -> None:
|
689
|
+
def scan_target() -> None:
|
690
|
+
try:
|
691
|
+
loop = asyncio.new_event_loop()
|
692
|
+
asyncio.set_event_loop(loop)
|
693
|
+
|
694
|
+
try:
|
695
|
+
agent = StrixAgent(self.agent_config)
|
696
|
+
|
697
|
+
if not self._scan_stop_event.is_set():
|
698
|
+
loop.run_until_complete(agent.execute_scan(self.scan_config))
|
699
|
+
|
700
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
701
|
+
logging.info("Scan interrupted by user")
|
702
|
+
except (ConnectionError, TimeoutError):
|
703
|
+
logging.exception("Network error during scan")
|
704
|
+
except RuntimeError:
|
705
|
+
logging.exception("Runtime error during scan")
|
706
|
+
except Exception:
|
707
|
+
logging.exception("Unexpected error during scan")
|
708
|
+
finally:
|
709
|
+
loop.close()
|
710
|
+
self._scan_completed.set()
|
711
|
+
|
712
|
+
except Exception:
|
713
|
+
logging.exception("Error setting up scan thread")
|
714
|
+
self._scan_completed.set()
|
715
|
+
|
716
|
+
self._scan_thread = threading.Thread(target=scan_target, daemon=True)
|
717
|
+
self._scan_thread.start()
|
718
|
+
|
719
|
+
def _add_agent_node(self, agent_data: dict[str, Any]) -> None:
|
720
|
+
if len(self.screen_stack) > 1 or self.show_splash:
|
721
|
+
return
|
722
|
+
|
723
|
+
if not self.is_mounted:
|
724
|
+
return
|
725
|
+
|
726
|
+
agent_id = agent_data["id"]
|
727
|
+
parent_id = agent_data.get("parent_id")
|
728
|
+
status = agent_data.get("status", "running")
|
729
|
+
|
730
|
+
try:
|
731
|
+
agents_tree = self.query_one("#agents_tree", Tree)
|
732
|
+
except (ValueError, Exception):
|
733
|
+
return
|
734
|
+
|
735
|
+
agent_name_raw = agent_data.get("name", "Agent")
|
736
|
+
|
737
|
+
status_indicators = {
|
738
|
+
"running": "🟢",
|
739
|
+
"waiting": "🟡",
|
740
|
+
"completed": "✅",
|
741
|
+
"failed": "❌",
|
742
|
+
"stopped": "⏹️",
|
743
|
+
"stopping": "⏸️",
|
744
|
+
}
|
745
|
+
|
746
|
+
status_icon = status_indicators.get(status, "🔵")
|
747
|
+
agent_name = f"{status_icon} {escape_markup(agent_name_raw)}"
|
748
|
+
|
749
|
+
if status in ["running", "waiting"]:
|
750
|
+
self._start_agent_verb_timer(agent_id)
|
751
|
+
|
752
|
+
try:
|
753
|
+
if parent_id and parent_id in self.agent_nodes:
|
754
|
+
parent_node = self.agent_nodes[parent_id]
|
755
|
+
agent_node = parent_node.add(
|
756
|
+
agent_name,
|
757
|
+
data={"agent_id": agent_id},
|
758
|
+
)
|
759
|
+
parent_node.allow_expand = True
|
760
|
+
else:
|
761
|
+
agent_node = agents_tree.root.add(
|
762
|
+
agent_name,
|
763
|
+
data={"agent_id": agent_id},
|
764
|
+
)
|
765
|
+
|
766
|
+
agent_node.allow_expand = False
|
767
|
+
agent_node.expand()
|
768
|
+
self.agent_nodes[agent_id] = agent_node
|
769
|
+
|
770
|
+
if len(self.agent_nodes) == 1:
|
771
|
+
agents_tree.select_node(agent_node)
|
772
|
+
self.selected_agent_id = agent_id
|
773
|
+
|
774
|
+
self._reorganize_orphaned_agents(agent_id)
|
775
|
+
except (AttributeError, ValueError, RuntimeError) as e:
|
776
|
+
import logging
|
777
|
+
|
778
|
+
logging.warning(f"Failed to add agent node {agent_id}: {e}")
|
779
|
+
|
780
|
+
def _expand_all_agent_nodes(self) -> None:
|
781
|
+
if len(self.screen_stack) > 1 or self.show_splash:
|
782
|
+
return
|
783
|
+
|
784
|
+
if not self.is_mounted:
|
785
|
+
return
|
786
|
+
|
787
|
+
try:
|
788
|
+
agents_tree = self.query_one("#agents_tree", Tree)
|
789
|
+
self._expand_node_recursively(agents_tree.root)
|
790
|
+
except (ValueError, Exception):
|
791
|
+
logging.debug("Tree not ready for expanding nodes")
|
792
|
+
|
793
|
+
def _expand_node_recursively(self, node: TreeNode) -> None:
|
794
|
+
if not node.is_expanded:
|
795
|
+
node.expand()
|
796
|
+
for child in node.children:
|
797
|
+
self._expand_node_recursively(child)
|
798
|
+
|
799
|
+
def _copy_node_under(self, node_to_copy: TreeNode, new_parent: TreeNode) -> None:
|
800
|
+
agent_id = node_to_copy.data["agent_id"]
|
801
|
+
agent_data = self.tracer.agents.get(agent_id, {})
|
802
|
+
agent_name_raw = agent_data.get("name", "Agent")
|
803
|
+
status = agent_data.get("status", "running")
|
804
|
+
|
805
|
+
status_indicators = {
|
806
|
+
"running": "🟢",
|
807
|
+
"waiting": "🟡",
|
808
|
+
"completed": "✅",
|
809
|
+
"failed": "❌",
|
810
|
+
"stopped": "⏹️",
|
811
|
+
"stopping": "⏸️",
|
812
|
+
}
|
813
|
+
|
814
|
+
status_icon = status_indicators.get(status, "🔵")
|
815
|
+
agent_name = f"{status_icon} {escape_markup(agent_name_raw)}"
|
816
|
+
|
817
|
+
new_node = new_parent.add(
|
818
|
+
agent_name,
|
819
|
+
data=node_to_copy.data,
|
820
|
+
)
|
821
|
+
new_node.allow_expand = node_to_copy.allow_expand
|
822
|
+
|
823
|
+
self.agent_nodes[agent_id] = new_node
|
824
|
+
|
825
|
+
for child in node_to_copy.children:
|
826
|
+
self._copy_node_under(child, new_node)
|
827
|
+
|
828
|
+
if node_to_copy.is_expanded:
|
829
|
+
new_node.expand()
|
830
|
+
|
831
|
+
def _reorganize_orphaned_agents(self, new_parent_id: str) -> None:
|
832
|
+
agents_to_move = []
|
833
|
+
|
834
|
+
for agent_id, agent_data in self.tracer.agents.items():
|
835
|
+
if (
|
836
|
+
agent_data.get("parent_id") == new_parent_id
|
837
|
+
and agent_id in self.agent_nodes
|
838
|
+
and agent_id != new_parent_id
|
839
|
+
):
|
840
|
+
agents_to_move.append(agent_id)
|
841
|
+
|
842
|
+
if not agents_to_move:
|
843
|
+
return
|
844
|
+
|
845
|
+
parent_node = self.agent_nodes[new_parent_id]
|
846
|
+
|
847
|
+
for child_agent_id in agents_to_move:
|
848
|
+
if child_agent_id in self.agent_nodes:
|
849
|
+
old_node = self.agent_nodes[child_agent_id]
|
850
|
+
|
851
|
+
if old_node.parent is parent_node:
|
852
|
+
continue
|
853
|
+
|
854
|
+
self._copy_node_under(old_node, parent_node)
|
855
|
+
|
856
|
+
old_node.remove()
|
857
|
+
|
858
|
+
parent_node.allow_expand = True
|
859
|
+
self._expand_all_agent_nodes()
|
860
|
+
|
861
|
+
def _render_chat_content(self, msg_data: dict[str, Any]) -> str:
|
862
|
+
role = msg_data.get("role")
|
863
|
+
content = escape_markup(msg_data.get("content", ""))
|
864
|
+
|
865
|
+
if not content:
|
866
|
+
return ""
|
867
|
+
|
868
|
+
if role == "user":
|
869
|
+
from strix.cli.tool_components.user_message_renderer import UserMessageRenderer
|
870
|
+
|
871
|
+
return UserMessageRenderer.render_simple(content)
|
872
|
+
return content
|
873
|
+
|
874
|
+
def _render_tool_content_simple(self, tool_data: dict[str, Any]) -> str:
|
875
|
+
tool_name = tool_data.get("tool_name", "Unknown Tool")
|
876
|
+
args = tool_data.get("args", {})
|
877
|
+
status = tool_data.get("status", "unknown")
|
878
|
+
result = tool_data.get("result")
|
879
|
+
|
880
|
+
tool_colors = {
|
881
|
+
"terminal_action": "#22c55e",
|
882
|
+
"browser_action": "#06b6d4",
|
883
|
+
"python_action": "#3b82f6",
|
884
|
+
"agents_graph_action": "#fbbf24",
|
885
|
+
"file_edit_action": "#10b981",
|
886
|
+
"proxy_action": "#06b6d4",
|
887
|
+
"notes_action": "#fbbf24",
|
888
|
+
"thinking_action": "#a855f7",
|
889
|
+
"web_search_action": "#22c55e",
|
890
|
+
"finish_action": "#dc2626",
|
891
|
+
"reporting_action": "#ea580c",
|
892
|
+
"scan_start_info": "#22c55e",
|
893
|
+
"subagent_start_info": "#22c55e",
|
894
|
+
}
|
895
|
+
|
896
|
+
color = tool_colors.get(tool_name, "#737373")
|
897
|
+
|
898
|
+
from strix.cli.tool_components.registry import get_tool_renderer
|
899
|
+
|
900
|
+
renderer = get_tool_renderer(tool_name)
|
901
|
+
|
902
|
+
if renderer:
|
903
|
+
widget = renderer.render(tool_data)
|
904
|
+
content = str(widget.renderable)
|
905
|
+
else:
|
906
|
+
status_icons = {
|
907
|
+
"running": "[yellow]●[/yellow]",
|
908
|
+
"completed": "[green]✓[/green]",
|
909
|
+
"failed": "[red]✗[/red]",
|
910
|
+
"error": "[red]✗[/red]",
|
911
|
+
}
|
912
|
+
status_icon = status_icons.get(status, "[dim]○[/dim]")
|
913
|
+
|
914
|
+
lines = [f"→ Using tool [bold blue]{escape_markup(tool_name)}[/] {status_icon}"]
|
915
|
+
|
916
|
+
if args:
|
917
|
+
for k, v in list(args.items())[:2]:
|
918
|
+
str_v = str(v)
|
919
|
+
if len(str_v) > 80:
|
920
|
+
str_v = str_v[:77] + "..."
|
921
|
+
lines.append(f" [dim]{k}:[/] {escape_markup(str_v)}")
|
922
|
+
|
923
|
+
if status in ["completed", "failed", "error"] and result:
|
924
|
+
result_str = str(result)
|
925
|
+
if len(result_str) > 150:
|
926
|
+
result_str = result_str[:147] + "..."
|
927
|
+
lines.append(f"[bold]Result:[/] {escape_markup(result_str)}")
|
928
|
+
|
929
|
+
content = "\n".join(lines)
|
930
|
+
|
931
|
+
lines = content.split("\n")
|
932
|
+
bordered_lines = [f"[{color}]▍[/{color}] {line}" for line in lines]
|
933
|
+
return "\n".join(bordered_lines)
|
934
|
+
|
935
|
+
@on(Tree.NodeHighlighted) # type: ignore[misc]
|
936
|
+
def handle_tree_highlight(self, event: Tree.NodeHighlighted) -> None:
|
937
|
+
if len(self.screen_stack) > 1 or self.show_splash:
|
938
|
+
return
|
939
|
+
|
940
|
+
if not self.is_mounted:
|
941
|
+
return
|
942
|
+
|
943
|
+
node = event.node
|
944
|
+
|
945
|
+
try:
|
946
|
+
agents_tree = self.query_one("#agents_tree", Tree)
|
947
|
+
except (ValueError, Exception):
|
948
|
+
return
|
949
|
+
|
950
|
+
if self.focused == agents_tree and node.data:
|
951
|
+
agent_id = node.data.get("agent_id")
|
952
|
+
if agent_id:
|
953
|
+
self.selected_agent_id = agent_id
|
954
|
+
|
955
|
+
def _send_user_message(self, message: str) -> None:
|
956
|
+
if not self.selected_agent_id:
|
957
|
+
return
|
958
|
+
|
959
|
+
if self.tracer:
|
960
|
+
self.tracer.log_chat_message(
|
961
|
+
content=message,
|
962
|
+
role="user",
|
963
|
+
agent_id=self.selected_agent_id,
|
964
|
+
)
|
965
|
+
|
966
|
+
try:
|
967
|
+
from strix.tools.agents_graph.agents_graph_actions import send_user_message_to_agent
|
968
|
+
|
969
|
+
send_user_message_to_agent(self.selected_agent_id, message)
|
970
|
+
|
971
|
+
except (ImportError, AttributeError) as e:
|
972
|
+
import logging
|
973
|
+
|
974
|
+
logging.warning(f"Failed to send message to agent {self.selected_agent_id}: {e}")
|
975
|
+
|
976
|
+
self._displayed_events.clear()
|
977
|
+
self._update_chat_view()
|
978
|
+
|
979
|
+
self.call_after_refresh(self._focus_chat_input)
|
980
|
+
|
981
|
+
def _get_agent_name(self, agent_id: str) -> str:
|
982
|
+
try:
|
983
|
+
if self.tracer and agent_id in self.tracer.agents:
|
984
|
+
agent_name = self.tracer.agents[agent_id].get("name")
|
985
|
+
if isinstance(agent_name, str):
|
986
|
+
return agent_name
|
987
|
+
except (KeyError, AttributeError) as e:
|
988
|
+
logging.warning(f"Could not retrieve agent name for {agent_id}: {e}")
|
989
|
+
return "Unknown Agent"
|
990
|
+
|
991
|
+
def action_toggle_help(self) -> None:
|
992
|
+
if self.show_splash or not self.is_mounted:
|
993
|
+
return
|
994
|
+
|
995
|
+
try:
|
996
|
+
self.query_one("#main_container")
|
997
|
+
except (ValueError, Exception):
|
998
|
+
return
|
999
|
+
|
1000
|
+
if isinstance(self.screen, HelpScreen):
|
1001
|
+
self.pop_screen()
|
1002
|
+
return
|
1003
|
+
|
1004
|
+
if len(self.screen_stack) > 1:
|
1005
|
+
return
|
1006
|
+
|
1007
|
+
self.push_screen(HelpScreen())
|
1008
|
+
|
1009
|
+
def action_request_quit(self) -> None:
|
1010
|
+
if self.show_splash or not self.is_mounted:
|
1011
|
+
self.action_custom_quit()
|
1012
|
+
return
|
1013
|
+
|
1014
|
+
if len(self.screen_stack) > 1:
|
1015
|
+
return
|
1016
|
+
|
1017
|
+
try:
|
1018
|
+
self.query_one("#main_container")
|
1019
|
+
except (ValueError, Exception):
|
1020
|
+
self.action_custom_quit()
|
1021
|
+
return
|
1022
|
+
|
1023
|
+
self.push_screen(QuitScreen())
|
1024
|
+
|
1025
|
+
def action_stop_selected_agent(self) -> None:
|
1026
|
+
if (
|
1027
|
+
self.show_splash
|
1028
|
+
or not self.is_mounted
|
1029
|
+
or len(self.screen_stack) > 1
|
1030
|
+
or not self.selected_agent_id
|
1031
|
+
):
|
1032
|
+
return
|
1033
|
+
|
1034
|
+
agent_name, should_stop = self._validate_agent_for_stopping()
|
1035
|
+
if not should_stop:
|
1036
|
+
return
|
1037
|
+
|
1038
|
+
try:
|
1039
|
+
self.query_one("#main_container")
|
1040
|
+
except (ValueError, Exception):
|
1041
|
+
return
|
1042
|
+
|
1043
|
+
self.push_screen(StopAgentScreen(agent_name, self.selected_agent_id))
|
1044
|
+
|
1045
|
+
def _validate_agent_for_stopping(self) -> tuple[str, bool]:
|
1046
|
+
agent_name = "Unknown Agent"
|
1047
|
+
|
1048
|
+
try:
|
1049
|
+
if self.tracer and self.selected_agent_id in self.tracer.agents:
|
1050
|
+
agent_data = self.tracer.agents[self.selected_agent_id]
|
1051
|
+
agent_name = agent_data.get("name", "Unknown Agent")
|
1052
|
+
|
1053
|
+
agent_status = agent_data.get("status", "running")
|
1054
|
+
if agent_status not in ["running"]:
|
1055
|
+
return agent_name, False
|
1056
|
+
|
1057
|
+
agent_events = self._gather_agent_events(self.selected_agent_id)
|
1058
|
+
if not agent_events:
|
1059
|
+
return agent_name, False
|
1060
|
+
|
1061
|
+
return agent_name, True
|
1062
|
+
|
1063
|
+
except (KeyError, AttributeError, ValueError) as e:
|
1064
|
+
import logging
|
1065
|
+
|
1066
|
+
logging.warning(f"Failed to gather agent events: {e}")
|
1067
|
+
|
1068
|
+
return agent_name, False
|
1069
|
+
|
1070
|
+
def action_confirm_stop_agent(self, agent_id: str) -> None:
|
1071
|
+
self.pop_screen()
|
1072
|
+
|
1073
|
+
try:
|
1074
|
+
from strix.tools.agents_graph.agents_graph_actions import stop_agent
|
1075
|
+
|
1076
|
+
result = stop_agent(agent_id)
|
1077
|
+
|
1078
|
+
import logging
|
1079
|
+
|
1080
|
+
if result.get("success"):
|
1081
|
+
logging.info(f"Stop request sent to agent: {result.get('message', 'Unknown')}")
|
1082
|
+
else:
|
1083
|
+
logging.warning(f"Failed to stop agent: {result.get('error', 'Unknown error')}")
|
1084
|
+
|
1085
|
+
except Exception:
|
1086
|
+
import logging
|
1087
|
+
|
1088
|
+
logging.exception(f"Failed to stop agent {agent_id}")
|
1089
|
+
|
1090
|
+
def action_custom_quit(self) -> None:
|
1091
|
+
for agent_id in list(self._agent_verb_timers.keys()):
|
1092
|
+
self._stop_agent_verb_timer(agent_id)
|
1093
|
+
|
1094
|
+
if self._scan_thread and self._scan_thread.is_alive():
|
1095
|
+
self._scan_stop_event.set()
|
1096
|
+
|
1097
|
+
self._scan_thread.join(timeout=1.0)
|
1098
|
+
|
1099
|
+
self.tracer.cleanup()
|
1100
|
+
|
1101
|
+
self.exit()
|
1102
|
+
|
1103
|
+
def _is_widget_safe(self, widget: Any) -> bool:
|
1104
|
+
try:
|
1105
|
+
_ = widget.screen
|
1106
|
+
except (AttributeError, ValueError, Exception):
|
1107
|
+
return False
|
1108
|
+
else:
|
1109
|
+
return bool(widget.is_mounted)
|
1110
|
+
|
1111
|
+
def _safe_widget_operation(
|
1112
|
+
self, operation: Callable[..., Any], *args: Any, **kwargs: Any
|
1113
|
+
) -> bool:
|
1114
|
+
try:
|
1115
|
+
operation(*args, **kwargs)
|
1116
|
+
except (AttributeError, ValueError, Exception):
|
1117
|
+
return False
|
1118
|
+
else:
|
1119
|
+
return True
|
1120
|
+
|
1121
|
+
|
1122
|
+
async def run_strix_cli(args: argparse.Namespace) -> None:
|
1123
|
+
app = StrixCLIApp(args)
|
1124
|
+
await app.run_async()
|