strix-agent 0.4.0__py3-none-any.whl → 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. strix/agents/StrixAgent/strix_agent.py +3 -3
  2. strix/agents/StrixAgent/system_prompt.jinja +30 -26
  3. strix/agents/base_agent.py +159 -75
  4. strix/agents/state.py +5 -2
  5. strix/config/__init__.py +12 -0
  6. strix/config/config.py +172 -0
  7. strix/interface/assets/tui_styles.tcss +195 -230
  8. strix/interface/cli.py +16 -41
  9. strix/interface/main.py +151 -74
  10. strix/interface/streaming_parser.py +119 -0
  11. strix/interface/tool_components/__init__.py +4 -0
  12. strix/interface/tool_components/agent_message_renderer.py +190 -0
  13. strix/interface/tool_components/agents_graph_renderer.py +54 -38
  14. strix/interface/tool_components/base_renderer.py +68 -36
  15. strix/interface/tool_components/browser_renderer.py +106 -91
  16. strix/interface/tool_components/file_edit_renderer.py +117 -36
  17. strix/interface/tool_components/finish_renderer.py +43 -10
  18. strix/interface/tool_components/notes_renderer.py +63 -38
  19. strix/interface/tool_components/proxy_renderer.py +133 -92
  20. strix/interface/tool_components/python_renderer.py +121 -8
  21. strix/interface/tool_components/registry.py +19 -12
  22. strix/interface/tool_components/reporting_renderer.py +196 -28
  23. strix/interface/tool_components/scan_info_renderer.py +22 -19
  24. strix/interface/tool_components/terminal_renderer.py +270 -90
  25. strix/interface/tool_components/thinking_renderer.py +8 -6
  26. strix/interface/tool_components/todo_renderer.py +225 -0
  27. strix/interface/tool_components/user_message_renderer.py +26 -19
  28. strix/interface/tool_components/web_search_renderer.py +7 -6
  29. strix/interface/tui.py +907 -262
  30. strix/interface/utils.py +236 -4
  31. strix/llm/__init__.py +6 -2
  32. strix/llm/config.py +8 -5
  33. strix/llm/dedupe.py +217 -0
  34. strix/llm/llm.py +209 -356
  35. strix/llm/memory_compressor.py +6 -5
  36. strix/llm/utils.py +17 -8
  37. strix/runtime/__init__.py +12 -3
  38. strix/runtime/docker_runtime.py +121 -202
  39. strix/runtime/tool_server.py +55 -95
  40. strix/skills/README.md +64 -0
  41. strix/skills/__init__.py +110 -0
  42. strix/{prompts → skills}/frameworks/nextjs.jinja +26 -0
  43. strix/skills/scan_modes/deep.jinja +145 -0
  44. strix/skills/scan_modes/quick.jinja +63 -0
  45. strix/skills/scan_modes/standard.jinja +91 -0
  46. strix/telemetry/README.md +38 -0
  47. strix/telemetry/__init__.py +7 -1
  48. strix/telemetry/posthog.py +137 -0
  49. strix/telemetry/tracer.py +194 -54
  50. strix/tools/__init__.py +11 -4
  51. strix/tools/agents_graph/agents_graph_actions.py +20 -21
  52. strix/tools/agents_graph/agents_graph_actions_schema.xml +8 -8
  53. strix/tools/browser/browser_actions.py +10 -6
  54. strix/tools/browser/browser_actions_schema.xml +6 -1
  55. strix/tools/browser/browser_instance.py +96 -48
  56. strix/tools/browser/tab_manager.py +121 -102
  57. strix/tools/context.py +12 -0
  58. strix/tools/executor.py +63 -4
  59. strix/tools/file_edit/file_edit_actions.py +6 -3
  60. strix/tools/file_edit/file_edit_actions_schema.xml +45 -3
  61. strix/tools/finish/finish_actions.py +80 -105
  62. strix/tools/finish/finish_actions_schema.xml +121 -14
  63. strix/tools/notes/notes_actions.py +6 -33
  64. strix/tools/notes/notes_actions_schema.xml +50 -46
  65. strix/tools/proxy/proxy_actions.py +14 -2
  66. strix/tools/proxy/proxy_actions_schema.xml +0 -1
  67. strix/tools/proxy/proxy_manager.py +28 -16
  68. strix/tools/python/python_actions.py +2 -2
  69. strix/tools/python/python_actions_schema.xml +9 -1
  70. strix/tools/python/python_instance.py +39 -37
  71. strix/tools/python/python_manager.py +43 -31
  72. strix/tools/registry.py +73 -12
  73. strix/tools/reporting/reporting_actions.py +218 -31
  74. strix/tools/reporting/reporting_actions_schema.xml +256 -8
  75. strix/tools/terminal/terminal_actions.py +2 -2
  76. strix/tools/terminal/terminal_actions_schema.xml +6 -0
  77. strix/tools/terminal/terminal_manager.py +41 -30
  78. strix/tools/thinking/thinking_actions_schema.xml +27 -25
  79. strix/tools/todo/__init__.py +18 -0
  80. strix/tools/todo/todo_actions.py +568 -0
  81. strix/tools/todo/todo_actions_schema.xml +225 -0
  82. strix/utils/__init__.py +0 -0
  83. strix/utils/resource_paths.py +13 -0
  84. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/METADATA +90 -65
  85. strix_agent-0.6.2.dist-info/RECORD +134 -0
  86. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/WHEEL +1 -1
  87. strix/llm/request_queue.py +0 -87
  88. strix/prompts/README.md +0 -64
  89. strix/prompts/__init__.py +0 -109
  90. strix_agent-0.4.0.dist-info/RECORD +0 -118
  91. /strix/{prompts → skills}/cloud/.gitkeep +0 -0
  92. /strix/{prompts → skills}/coordination/root_agent.jinja +0 -0
  93. /strix/{prompts → skills}/custom/.gitkeep +0 -0
  94. /strix/{prompts → skills}/frameworks/fastapi.jinja +0 -0
  95. /strix/{prompts → skills}/protocols/graphql.jinja +0 -0
  96. /strix/{prompts → skills}/reconnaissance/.gitkeep +0 -0
  97. /strix/{prompts → skills}/technologies/firebase_firestore.jinja +0 -0
  98. /strix/{prompts → skills}/technologies/supabase.jinja +0 -0
  99. /strix/{prompts → skills}/vulnerabilities/authentication_jwt.jinja +0 -0
  100. /strix/{prompts → skills}/vulnerabilities/broken_function_level_authorization.jinja +0 -0
  101. /strix/{prompts → skills}/vulnerabilities/business_logic.jinja +0 -0
  102. /strix/{prompts → skills}/vulnerabilities/csrf.jinja +0 -0
  103. /strix/{prompts → skills}/vulnerabilities/idor.jinja +0 -0
  104. /strix/{prompts → skills}/vulnerabilities/information_disclosure.jinja +0 -0
  105. /strix/{prompts → skills}/vulnerabilities/insecure_file_uploads.jinja +0 -0
  106. /strix/{prompts → skills}/vulnerabilities/mass_assignment.jinja +0 -0
  107. /strix/{prompts → skills}/vulnerabilities/open_redirect.jinja +0 -0
  108. /strix/{prompts → skills}/vulnerabilities/path_traversal_lfi_rfi.jinja +0 -0
  109. /strix/{prompts → skills}/vulnerabilities/race_conditions.jinja +0 -0
  110. /strix/{prompts → skills}/vulnerabilities/rce.jinja +0 -0
  111. /strix/{prompts → skills}/vulnerabilities/sql_injection.jinja +0 -0
  112. /strix/{prompts → skills}/vulnerabilities/ssrf.jinja +0 -0
  113. /strix/{prompts → skills}/vulnerabilities/subdomain_takeover.jinja +0 -0
  114. /strix/{prompts → skills}/vulnerabilities/xss.jinja +0 -0
  115. /strix/{prompts → skills}/vulnerabilities/xxe.jinja +0 -0
  116. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/entry_points.txt +0 -0
  117. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info/licenses}/LICENSE +0 -0
@@ -1,131 +1,311 @@
1
+ import re
2
+ from functools import cache
1
3
  from typing import Any, ClassVar
2
4
 
5
+ from pygments.lexers import get_lexer_by_name
6
+ from pygments.styles import get_style_by_name
7
+ from rich.text import Text
3
8
  from textual.widgets import Static
4
9
 
5
10
  from .base_renderer import BaseToolRenderer
6
11
  from .registry import register_tool_renderer
7
12
 
8
13
 
14
+ MAX_OUTPUT_LINES = 50
15
+ MAX_LINE_LENGTH = 200
16
+
17
+ STRIP_PATTERNS = [
18
+ (
19
+ r"\n?\[Command still running after [\d.]+s - showing output so far\.?"
20
+ r"\s*(?:Use C-c to interrupt if needed\.)?\]"
21
+ ),
22
+ r"^\[Below is the output of the previous command\.\]\n?",
23
+ r"^No command is currently running\. Cannot send input\.$",
24
+ (
25
+ r"^A command is already running\. Use is_input=true to send input to it, "
26
+ r"or interrupt it first \(e\.g\., with C-c\)\.$"
27
+ ),
28
+ ]
29
+
30
+
31
+ @cache
32
+ def _get_style_colors() -> dict[Any, str]:
33
+ style = get_style_by_name("native")
34
+ return {token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]}
35
+
36
+
9
37
  @register_tool_renderer
10
38
  class TerminalRenderer(BaseToolRenderer):
11
39
  tool_name: ClassVar[str] = "terminal_execute"
12
40
  css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool"]
13
41
 
42
+ CONTROL_SEQUENCES: ClassVar[set[str]] = {
43
+ "C-c",
44
+ "C-d",
45
+ "C-z",
46
+ "C-a",
47
+ "C-e",
48
+ "C-k",
49
+ "C-l",
50
+ "C-u",
51
+ "C-w",
52
+ "C-r",
53
+ "C-s",
54
+ "C-t",
55
+ "C-y",
56
+ "^c",
57
+ "^d",
58
+ "^z",
59
+ "^a",
60
+ "^e",
61
+ "^k",
62
+ "^l",
63
+ "^u",
64
+ "^w",
65
+ "^r",
66
+ "^s",
67
+ "^t",
68
+ "^y",
69
+ }
70
+ SPECIAL_KEYS: ClassVar[set[str]] = {
71
+ "Enter",
72
+ "Escape",
73
+ "Space",
74
+ "Tab",
75
+ "BTab",
76
+ "BSpace",
77
+ "DC",
78
+ "IC",
79
+ "Up",
80
+ "Down",
81
+ "Left",
82
+ "Right",
83
+ "Home",
84
+ "End",
85
+ "PageUp",
86
+ "PageDown",
87
+ "PgUp",
88
+ "PgDn",
89
+ "PPage",
90
+ "NPage",
91
+ "F1",
92
+ "F2",
93
+ "F3",
94
+ "F4",
95
+ "F5",
96
+ "F6",
97
+ "F7",
98
+ "F8",
99
+ "F9",
100
+ "F10",
101
+ "F11",
102
+ "F12",
103
+ }
104
+
105
+ @classmethod
106
+ def _get_token_color(cls, token_type: Any) -> str | None:
107
+ colors = _get_style_colors()
108
+ while token_type:
109
+ if token_type in colors:
110
+ return colors[token_type]
111
+ token_type = token_type.parent
112
+ return None
113
+
114
+ @classmethod
115
+ def _highlight_bash(cls, code: str) -> Text:
116
+ lexer = get_lexer_by_name("bash")
117
+ text = Text()
118
+
119
+ for token_type, token_value in lexer.get_tokens(code):
120
+ if not token_value:
121
+ continue
122
+ color = cls._get_token_color(token_type)
123
+ text.append(token_value, style=color)
124
+
125
+ return text
126
+
14
127
  @classmethod
15
128
  def render(cls, tool_data: dict[str, Any]) -> Static:
16
129
  args = tool_data.get("args", {})
17
130
  status = tool_data.get("status", "unknown")
18
- result = tool_data.get("result", {})
131
+ result = tool_data.get("result")
19
132
 
20
133
  command = args.get("command", "")
21
134
  is_input = args.get("is_input", False)
22
- terminal_id = args.get("terminal_id", "default")
23
- timeout = args.get("timeout")
24
135
 
25
- content = cls._build_sleek_content(command, is_input, terminal_id, timeout, result)
136
+ content = cls._build_content(command, is_input, status, result)
26
137
 
27
138
  css_classes = cls.get_css_classes(status)
28
139
  return Static(content, classes=css_classes)
29
140
 
30
141
  @classmethod
31
- def _build_sleek_content(
32
- cls,
33
- command: str,
34
- is_input: bool,
35
- terminal_id: str, # noqa: ARG003
36
- timeout: float | None, # noqa: ARG003
37
- result: dict[str, Any], # noqa: ARG003
38
- ) -> str:
142
+ def _build_content(
143
+ cls, command: str, is_input: bool, status: str, result: dict[str, Any] | str | None
144
+ ) -> Text:
145
+ text = Text()
39
146
  terminal_icon = ">_"
40
147
 
41
148
  if not command.strip():
42
- return f"{terminal_icon} [dim]getting logs...[/]"
43
-
44
- control_sequences = {
45
- "C-c",
46
- "C-d",
47
- "C-z",
48
- "C-a",
49
- "C-e",
50
- "C-k",
51
- "C-l",
52
- "C-u",
53
- "C-w",
54
- "C-r",
55
- "C-s",
56
- "C-t",
57
- "C-y",
58
- "^c",
59
- "^d",
60
- "^z",
61
- "^a",
62
- "^e",
63
- "^k",
64
- "^l",
65
- "^u",
66
- "^w",
67
- "^r",
68
- "^s",
69
- "^t",
70
- "^y",
71
- }
72
- special_keys = {
73
- "Enter",
74
- "Escape",
75
- "Space",
76
- "Tab",
77
- "BTab",
78
- "BSpace",
79
- "DC",
80
- "IC",
81
- "Up",
82
- "Down",
83
- "Left",
84
- "Right",
85
- "Home",
86
- "End",
87
- "PageUp",
88
- "PageDown",
89
- "PgUp",
90
- "PgDn",
91
- "PPage",
92
- "NPage",
93
- "F1",
94
- "F2",
95
- "F3",
96
- "F4",
97
- "F5",
98
- "F6",
99
- "F7",
100
- "F8",
101
- "F9",
102
- "F10",
103
- "F11",
104
- "F12",
105
- }
149
+ text.append(terminal_icon, style="dim")
150
+ text.append(" ")
151
+ text.append("getting logs...", style="dim")
152
+ if result:
153
+ cls._append_output(text, result, status, command)
154
+ return text
106
155
 
107
156
  is_special = (
108
- command in control_sequences
109
- or command in special_keys
157
+ command in cls.CONTROL_SEQUENCES
158
+ or command in cls.SPECIAL_KEYS
110
159
  or command.startswith(("M-", "S-", "C-S-", "C-M-", "S-M-"))
111
160
  )
112
161
 
162
+ text.append(terminal_icon, style="dim")
163
+ text.append(" ")
164
+
113
165
  if is_special:
114
- return f"{terminal_icon} [#ef4444]{cls.escape_markup(command)}[/]"
166
+ text.append(command, style="#ef4444")
167
+ elif is_input:
168
+ text.append(">>>", style="#3b82f6")
169
+ text.append(" ")
170
+ text.append_text(cls._format_command(command))
171
+ else:
172
+ text.append("$", style="#22c55e")
173
+ text.append(" ")
174
+ text.append_text(cls._format_command(command))
115
175
 
116
- if is_input:
117
- formatted_command = cls._format_command_display(command)
118
- return f"{terminal_icon} [#3b82f6]>>>[/] [#22c55e]{formatted_command}[/]"
176
+ if result:
177
+ cls._append_output(text, result, status, command)
119
178
 
120
- formatted_command = cls._format_command_display(command)
121
- return f"{terminal_icon} [#22c55e]$ {formatted_command}[/]"
179
+ return text
122
180
 
123
181
  @classmethod
124
- def _format_command_display(cls, command: str) -> str:
125
- if not command:
126
- return ""
182
+ def _clean_output(cls, output: str, command: str = "") -> str:
183
+ cleaned = output
184
+
185
+ for pattern in STRIP_PATTERNS:
186
+ cleaned = re.sub(pattern, "", cleaned, flags=re.MULTILINE)
187
+
188
+ if cleaned.strip():
189
+ lines = cleaned.splitlines()
190
+ filtered_lines: list[str] = []
191
+ for line in lines:
192
+ if not filtered_lines and not line.strip():
193
+ continue
194
+ if re.match(r"^\[STRIX_\d+\]\$\s*", line):
195
+ continue
196
+ if command and line.strip() == command.strip():
197
+ continue
198
+ if command and re.match(r"^[\$#>]\s*" + re.escape(command.strip()) + r"\s*$", line):
199
+ continue
200
+ filtered_lines.append(line)
127
201
 
128
- if len(command) > 400:
129
- command = command[:397] + "..."
202
+ while filtered_lines and re.match(r"^\[STRIX_\d+\]\$\s*", filtered_lines[-1]):
203
+ filtered_lines.pop()
204
+
205
+ cleaned = "\n".join(filtered_lines)
206
+
207
+ return cleaned.strip()
208
+
209
+ @classmethod
210
+ def _append_output(
211
+ cls, text: Text, result: dict[str, Any] | str, tool_status: str, command: str = ""
212
+ ) -> None:
213
+ if isinstance(result, str):
214
+ if result.strip():
215
+ text.append("\n")
216
+ text.append_text(cls._format_output(result))
217
+ return
130
218
 
131
- return cls.escape_markup(command)
219
+ raw_output = result.get("content", "")
220
+ output = cls._clean_output(raw_output, command)
221
+ error = result.get("error")
222
+ exit_code = result.get("exit_code")
223
+ result_status = result.get("status", "")
224
+
225
+ if error and not cls._is_status_message(error):
226
+ text.append("\n")
227
+ text.append(" error: ", style="bold #ef4444")
228
+ text.append(cls._truncate_line(error), style="#ef4444")
229
+ return
230
+
231
+ if result_status == "running" or tool_status == "running":
232
+ if output and output.strip():
233
+ text.append("\n")
234
+ formatted_output = cls._format_output(output)
235
+ text.append_text(formatted_output)
236
+ return
237
+
238
+ if not output or not output.strip():
239
+ if exit_code is not None and exit_code != 0:
240
+ text.append("\n")
241
+ text.append(f" exit {exit_code}", style="dim #ef4444")
242
+ return
243
+
244
+ text.append("\n")
245
+ formatted_output = cls._format_output(output)
246
+ text.append_text(formatted_output)
247
+
248
+ if exit_code is not None and exit_code != 0:
249
+ text.append("\n")
250
+ text.append(f" exit {exit_code}", style="dim #ef4444")
251
+
252
+ @classmethod
253
+ def _is_status_message(cls, message: str) -> bool:
254
+ status_patterns = [
255
+ r"No command is currently running",
256
+ r"A command is already running",
257
+ r"Cannot send input",
258
+ r"Use is_input=true",
259
+ r"Use C-c to interrupt",
260
+ r"showing output so far",
261
+ ]
262
+ return any(re.search(pattern, message) for pattern in status_patterns)
263
+
264
+ @classmethod
265
+ def _format_output(cls, output: str) -> Text:
266
+ text = Text()
267
+ lines = output.splitlines()
268
+ total_lines = len(lines)
269
+
270
+ head_count = MAX_OUTPUT_LINES // 2
271
+ tail_count = MAX_OUTPUT_LINES - head_count - 1
272
+
273
+ if total_lines <= MAX_OUTPUT_LINES:
274
+ display_lines = lines
275
+ truncated = False
276
+ hidden_count = 0
277
+ else:
278
+ display_lines = lines[:head_count]
279
+ truncated = True
280
+ hidden_count = total_lines - head_count - tail_count
281
+
282
+ for i, line in enumerate(display_lines):
283
+ truncated_line = cls._truncate_line(line)
284
+ text.append(" ")
285
+ text.append(truncated_line, style="dim")
286
+ if i < len(display_lines) - 1 or truncated:
287
+ text.append("\n")
288
+
289
+ if truncated:
290
+ text.append(f" ... {hidden_count} lines truncated ...", style="dim italic")
291
+ text.append("\n")
292
+ tail_lines = lines[-tail_count:]
293
+ for i, line in enumerate(tail_lines):
294
+ truncated_line = cls._truncate_line(line)
295
+ text.append(" ")
296
+ text.append(truncated_line, style="dim")
297
+ if i < len(tail_lines) - 1:
298
+ text.append("\n")
299
+
300
+ return text
301
+
302
+ @classmethod
303
+ def _truncate_line(cls, line: str) -> str:
304
+ clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
305
+ if len(clean_line) > MAX_LINE_LENGTH:
306
+ return line[: MAX_LINE_LENGTH - 3] + "..."
307
+ return line
308
+
309
+ @classmethod
310
+ def _format_command(cls, command: str) -> Text:
311
+ return cls._highlight_bash(command)
@@ -1,5 +1,6 @@
1
1
  from typing import Any, ClassVar
2
2
 
3
+ from rich.text import Text
3
4
  from textual.widgets import Static
4
5
 
5
6
  from .base_renderer import BaseToolRenderer
@@ -14,16 +15,17 @@ class ThinkRenderer(BaseToolRenderer):
14
15
  @classmethod
15
16
  def render(cls, tool_data: dict[str, Any]) -> Static:
16
17
  args = tool_data.get("args", {})
17
-
18
18
  thought = args.get("thought", "")
19
19
 
20
- header = "🧠 [bold #a855f7]Thinking[/]"
20
+ text = Text()
21
+ text.append("🧠 ")
22
+ text.append("Thinking", style="bold #a855f7")
23
+ text.append("\n ")
21
24
 
22
25
  if thought:
23
- thought_display = thought[:600] + "..." if len(thought) > 600 else thought
24
- content = f"{header}\n [italic dim]{cls.escape_markup(thought_display)}[/]"
26
+ text.append(thought, style="italic dim")
25
27
  else:
26
- content = f"{header}\n [italic dim]Thinking...[/]"
28
+ text.append("Thinking...", style="italic dim")
27
29
 
28
30
  css_classes = cls.get_css_classes("completed")
29
- return Static(content, classes=css_classes)
31
+ return Static(text, classes=css_classes)
@@ -0,0 +1,225 @@
1
+ from typing import Any, ClassVar
2
+
3
+ from rich.text import Text
4
+ from textual.widgets import Static
5
+
6
+ from .base_renderer import BaseToolRenderer
7
+ from .registry import register_tool_renderer
8
+
9
+
10
+ STATUS_MARKERS: dict[str, str] = {
11
+ "pending": "[ ]",
12
+ "in_progress": "[~]",
13
+ "done": "[•]",
14
+ }
15
+
16
+
17
+ def _format_todo_lines(text: Text, result: dict[str, Any]) -> None:
18
+ todos = result.get("todos")
19
+ if not isinstance(todos, list) or not todos:
20
+ text.append("\n ")
21
+ text.append("No todos", style="dim")
22
+ return
23
+
24
+ for todo in todos:
25
+ status = todo.get("status", "pending")
26
+ marker = STATUS_MARKERS.get(status, STATUS_MARKERS["pending"])
27
+
28
+ title = todo.get("title", "").strip() or "(untitled)"
29
+
30
+ text.append("\n ")
31
+ text.append(marker)
32
+ text.append(" ")
33
+
34
+ if status == "done":
35
+ text.append(title, style="dim strike")
36
+ elif status == "in_progress":
37
+ text.append(title, style="italic")
38
+ else:
39
+ text.append(title)
40
+
41
+
42
+ @register_tool_renderer
43
+ class CreateTodoRenderer(BaseToolRenderer):
44
+ tool_name: ClassVar[str] = "create_todo"
45
+ css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
46
+
47
+ @classmethod
48
+ def render(cls, tool_data: dict[str, Any]) -> Static:
49
+ result = tool_data.get("result")
50
+
51
+ text = Text()
52
+ text.append("📋 ")
53
+ text.append("Todo", style="bold #a78bfa")
54
+
55
+ if isinstance(result, str) and result.strip():
56
+ text.append("\n ")
57
+ text.append(result.strip(), style="dim")
58
+ elif result and isinstance(result, dict):
59
+ if result.get("success"):
60
+ _format_todo_lines(text, result)
61
+ else:
62
+ error = result.get("error", "Failed to create todo")
63
+ text.append("\n ")
64
+ text.append(error, style="#ef4444")
65
+ else:
66
+ text.append("\n ")
67
+ text.append("Creating...", style="dim")
68
+
69
+ css_classes = cls.get_css_classes("completed")
70
+ return Static(text, classes=css_classes)
71
+
72
+
73
+ @register_tool_renderer
74
+ class ListTodosRenderer(BaseToolRenderer):
75
+ tool_name: ClassVar[str] = "list_todos"
76
+ css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
77
+
78
+ @classmethod
79
+ def render(cls, tool_data: dict[str, Any]) -> Static:
80
+ result = tool_data.get("result")
81
+
82
+ text = Text()
83
+ text.append("📋 ")
84
+ text.append("Todos", style="bold #a78bfa")
85
+
86
+ if isinstance(result, str) and result.strip():
87
+ text.append("\n ")
88
+ text.append(result.strip(), style="dim")
89
+ elif result and isinstance(result, dict):
90
+ if result.get("success"):
91
+ _format_todo_lines(text, result)
92
+ else:
93
+ error = result.get("error", "Unable to list todos")
94
+ text.append("\n ")
95
+ text.append(error, style="#ef4444")
96
+ else:
97
+ text.append("\n ")
98
+ text.append("Loading...", style="dim")
99
+
100
+ css_classes = cls.get_css_classes("completed")
101
+ return Static(text, classes=css_classes)
102
+
103
+
104
+ @register_tool_renderer
105
+ class UpdateTodoRenderer(BaseToolRenderer):
106
+ tool_name: ClassVar[str] = "update_todo"
107
+ css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
108
+
109
+ @classmethod
110
+ def render(cls, tool_data: dict[str, Any]) -> Static:
111
+ result = tool_data.get("result")
112
+
113
+ text = Text()
114
+ text.append("📋 ")
115
+ text.append("Todo Updated", style="bold #a78bfa")
116
+
117
+ if isinstance(result, str) and result.strip():
118
+ text.append("\n ")
119
+ text.append(result.strip(), style="dim")
120
+ elif result and isinstance(result, dict):
121
+ if result.get("success"):
122
+ _format_todo_lines(text, result)
123
+ else:
124
+ error = result.get("error", "Failed to update todo")
125
+ text.append("\n ")
126
+ text.append(error, style="#ef4444")
127
+ else:
128
+ text.append("\n ")
129
+ text.append("Updating...", style="dim")
130
+
131
+ css_classes = cls.get_css_classes("completed")
132
+ return Static(text, classes=css_classes)
133
+
134
+
135
+ @register_tool_renderer
136
+ class MarkTodoDoneRenderer(BaseToolRenderer):
137
+ tool_name: ClassVar[str] = "mark_todo_done"
138
+ css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
139
+
140
+ @classmethod
141
+ def render(cls, tool_data: dict[str, Any]) -> Static:
142
+ result = tool_data.get("result")
143
+
144
+ text = Text()
145
+ text.append("📋 ")
146
+ text.append("Todo Completed", style="bold #a78bfa")
147
+
148
+ if isinstance(result, str) and result.strip():
149
+ text.append("\n ")
150
+ text.append(result.strip(), style="dim")
151
+ elif result and isinstance(result, dict):
152
+ if result.get("success"):
153
+ _format_todo_lines(text, result)
154
+ else:
155
+ error = result.get("error", "Failed to mark todo done")
156
+ text.append("\n ")
157
+ text.append(error, style="#ef4444")
158
+ else:
159
+ text.append("\n ")
160
+ text.append("Marking done...", style="dim")
161
+
162
+ css_classes = cls.get_css_classes("completed")
163
+ return Static(text, classes=css_classes)
164
+
165
+
166
+ @register_tool_renderer
167
+ class MarkTodoPendingRenderer(BaseToolRenderer):
168
+ tool_name: ClassVar[str] = "mark_todo_pending"
169
+ css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
170
+
171
+ @classmethod
172
+ def render(cls, tool_data: dict[str, Any]) -> Static:
173
+ result = tool_data.get("result")
174
+
175
+ text = Text()
176
+ text.append("📋 ")
177
+ text.append("Todo Reopened", style="bold #f59e0b")
178
+
179
+ if isinstance(result, str) and result.strip():
180
+ text.append("\n ")
181
+ text.append(result.strip(), style="dim")
182
+ elif result and isinstance(result, dict):
183
+ if result.get("success"):
184
+ _format_todo_lines(text, result)
185
+ else:
186
+ error = result.get("error", "Failed to reopen todo")
187
+ text.append("\n ")
188
+ text.append(error, style="#ef4444")
189
+ else:
190
+ text.append("\n ")
191
+ text.append("Reopening...", style="dim")
192
+
193
+ css_classes = cls.get_css_classes("completed")
194
+ return Static(text, classes=css_classes)
195
+
196
+
197
+ @register_tool_renderer
198
+ class DeleteTodoRenderer(BaseToolRenderer):
199
+ tool_name: ClassVar[str] = "delete_todo"
200
+ css_classes: ClassVar[list[str]] = ["tool-call", "todo-tool"]
201
+
202
+ @classmethod
203
+ def render(cls, tool_data: dict[str, Any]) -> Static:
204
+ result = tool_data.get("result")
205
+
206
+ text = Text()
207
+ text.append("📋 ")
208
+ text.append("Todo Removed", style="bold #94a3b8")
209
+
210
+ if isinstance(result, str) and result.strip():
211
+ text.append("\n ")
212
+ text.append(result.strip(), style="dim")
213
+ elif result and isinstance(result, dict):
214
+ if result.get("success"):
215
+ _format_todo_lines(text, result)
216
+ else:
217
+ error = result.get("error", "Failed to remove todo")
218
+ text.append("\n ")
219
+ text.append(error, style="#ef4444")
220
+ else:
221
+ text.append("\n ")
222
+ text.append("Removing...", style="dim")
223
+
224
+ css_classes = cls.get_css_classes("completed")
225
+ return Static(text, classes=css_classes)