ripperdoc 0.3.0__py3-none-any.whl → 0.3.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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +9 -1
- ripperdoc/cli/commands/agents_cmd.py +93 -53
- ripperdoc/cli/commands/mcp_cmd.py +3 -0
- ripperdoc/cli/commands/models_cmd.py +768 -283
- ripperdoc/cli/commands/permissions_cmd.py +107 -52
- ripperdoc/cli/commands/resume_cmd.py +61 -51
- ripperdoc/cli/commands/themes_cmd.py +31 -1
- ripperdoc/cli/ui/agents_tui/__init__.py +3 -0
- ripperdoc/cli/ui/agents_tui/textual_app.py +1138 -0
- ripperdoc/cli/ui/choice.py +376 -0
- ripperdoc/cli/ui/interrupt_listener.py +233 -0
- ripperdoc/cli/ui/message_display.py +7 -0
- ripperdoc/cli/ui/models_tui/__init__.py +5 -0
- ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
- ripperdoc/cli/ui/panels.py +19 -4
- ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
- ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
- ripperdoc/cli/ui/provider_options.py +220 -80
- ripperdoc/cli/ui/rich_ui.py +91 -83
- ripperdoc/cli/ui/tips.py +89 -0
- ripperdoc/cli/ui/wizard.py +98 -45
- ripperdoc/core/config.py +3 -0
- ripperdoc/core/permissions.py +66 -104
- ripperdoc/core/providers/anthropic.py +11 -0
- ripperdoc/protocol/stdio.py +3 -1
- ripperdoc/tools/bash_tool.py +2 -0
- ripperdoc/tools/file_edit_tool.py +100 -181
- ripperdoc/tools/file_read_tool.py +101 -25
- ripperdoc/tools/multi_edit_tool.py +239 -91
- ripperdoc/tools/notebook_edit_tool.py +11 -29
- ripperdoc/utils/file_editing.py +164 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +39 -30
- ripperdoc/cli/ui/interrupt_handler.py +0 -208
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
"""Textual app for managing agent definitions and runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable, Optional
|
|
8
|
+
|
|
9
|
+
from rich import box
|
|
10
|
+
from rich.console import Group
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from textual.app import App, ComposeResult
|
|
17
|
+
from textual.containers import Container, Horizontal, VerticalScroll
|
|
18
|
+
from textual.screen import ModalScreen
|
|
19
|
+
from textual.worker import Worker, WorkerState
|
|
20
|
+
from textual.widgets import (
|
|
21
|
+
Button,
|
|
22
|
+
DataTable,
|
|
23
|
+
Footer,
|
|
24
|
+
Header,
|
|
25
|
+
Input,
|
|
26
|
+
LoadingIndicator,
|
|
27
|
+
Select,
|
|
28
|
+
Static,
|
|
29
|
+
TextArea,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from ripperdoc.core.agents import (
|
|
33
|
+
AgentDefinition,
|
|
34
|
+
AgentLocation,
|
|
35
|
+
delete_agent_definition,
|
|
36
|
+
load_agent_definitions,
|
|
37
|
+
save_agent_definition,
|
|
38
|
+
)
|
|
39
|
+
from ripperdoc.core.config import get_global_config
|
|
40
|
+
from ripperdoc.core.default_tools import BUILTIN_TOOL_NAMES
|
|
41
|
+
from ripperdoc.core.query import query_llm
|
|
42
|
+
from ripperdoc.tools.task_tool import (
|
|
43
|
+
cancel_agent_run,
|
|
44
|
+
get_agent_run_snapshot,
|
|
45
|
+
list_agent_runs,
|
|
46
|
+
)
|
|
47
|
+
from ripperdoc.utils.json_utils import safe_parse_json
|
|
48
|
+
from ripperdoc.utils.messages import create_user_message
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AgentFormResult:
|
|
53
|
+
name: str
|
|
54
|
+
description: str
|
|
55
|
+
tools: list[str]
|
|
56
|
+
system_prompt: str
|
|
57
|
+
location: AgentLocation
|
|
58
|
+
model: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class AgentDraft:
|
|
63
|
+
name: str
|
|
64
|
+
description: str
|
|
65
|
+
tools: list[str]
|
|
66
|
+
system_prompt: str
|
|
67
|
+
location: AgentLocation
|
|
68
|
+
model: Optional[str] = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ConfirmScreen(ModalScreen[bool]):
|
|
72
|
+
"""Simple confirmation dialog."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, message: str) -> None:
|
|
75
|
+
super().__init__()
|
|
76
|
+
self._message = message
|
|
77
|
+
|
|
78
|
+
def compose(self) -> ComposeResult:
|
|
79
|
+
with Container(id="confirm_dialog"):
|
|
80
|
+
yield Static(self._message, id="confirm_message")
|
|
81
|
+
with Horizontal(id="confirm_buttons"):
|
|
82
|
+
yield Button("Yes", id="confirm_yes", variant="primary")
|
|
83
|
+
yield Button("No", id="confirm_no")
|
|
84
|
+
|
|
85
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
86
|
+
if event.button.id == "confirm_yes":
|
|
87
|
+
self.dismiss(True)
|
|
88
|
+
else:
|
|
89
|
+
self.dismiss(False)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class InfoScreen(ModalScreen[None]):
|
|
93
|
+
"""Modal screen for showing rich details."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, title: str, renderable: Any) -> None:
|
|
96
|
+
super().__init__()
|
|
97
|
+
self._title = title
|
|
98
|
+
self._renderable = renderable
|
|
99
|
+
|
|
100
|
+
def compose(self) -> ComposeResult:
|
|
101
|
+
with Container(id="info_dialog"):
|
|
102
|
+
yield Static(self._title, id="info_title")
|
|
103
|
+
with VerticalScroll(id="info_body"):
|
|
104
|
+
yield Static(self._renderable, id="info_content")
|
|
105
|
+
with Horizontal(id="info_buttons"):
|
|
106
|
+
yield Button("Close", id="info_close", variant="primary")
|
|
107
|
+
|
|
108
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
109
|
+
if event.button.id == "info_close":
|
|
110
|
+
self.dismiss(None)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class CreateMethodScreen(ModalScreen[Optional[str]]):
|
|
114
|
+
"""Select how to create a new agent."""
|
|
115
|
+
|
|
116
|
+
BINDINGS = [("escape", "cancel", "Cancel")]
|
|
117
|
+
|
|
118
|
+
def compose(self) -> ComposeResult:
|
|
119
|
+
with Container(id="method_dialog"):
|
|
120
|
+
yield Static("Create new agent", id="method_title")
|
|
121
|
+
yield Static("Creation method", id="method_subtitle")
|
|
122
|
+
yield Static(
|
|
123
|
+
"Choose how to create the agent configuration.",
|
|
124
|
+
id="method_hint",
|
|
125
|
+
)
|
|
126
|
+
with Horizontal(id="method_buttons"):
|
|
127
|
+
yield Button("Generate with AI", id="method_generate", variant="primary")
|
|
128
|
+
yield Button("Manual configuration", id="method_manual")
|
|
129
|
+
yield Button("Cancel", id="method_cancel")
|
|
130
|
+
|
|
131
|
+
def action_cancel(self) -> None:
|
|
132
|
+
self.dismiss(None)
|
|
133
|
+
|
|
134
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
135
|
+
if event.button.id == "method_generate":
|
|
136
|
+
self.dismiss("generate")
|
|
137
|
+
elif event.button.id == "method_manual":
|
|
138
|
+
self.dismiss("manual")
|
|
139
|
+
else:
|
|
140
|
+
self.dismiss(None)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class AgentGenerateScreen(ModalScreen[Optional[str]]):
|
|
144
|
+
"""Collect a description for AI-generated agent configs."""
|
|
145
|
+
|
|
146
|
+
BINDINGS = [("escape", "cancel", "Cancel")]
|
|
147
|
+
|
|
148
|
+
def __init__(self) -> None:
|
|
149
|
+
super().__init__()
|
|
150
|
+
self._busy: bool = False
|
|
151
|
+
|
|
152
|
+
def on_mount(self) -> None:
|
|
153
|
+
spinner = self.query_one("#generate_spinner", LoadingIndicator)
|
|
154
|
+
spinner.display = False
|
|
155
|
+
|
|
156
|
+
def compose(self) -> ComposeResult:
|
|
157
|
+
with Container(id="generate_dialog"):
|
|
158
|
+
yield Static("Create new agent", id="generate_title")
|
|
159
|
+
yield Static(
|
|
160
|
+
"Describe what this agent should do and when it should be used.",
|
|
161
|
+
id="generate_subtitle",
|
|
162
|
+
)
|
|
163
|
+
yield Static("", id="generate_error")
|
|
164
|
+
yield Static("", id="generate_status")
|
|
165
|
+
with VerticalScroll(id="generate_fields"):
|
|
166
|
+
yield TextArea(
|
|
167
|
+
id="generate_input",
|
|
168
|
+
placeholder="e.g., Help me write unit tests for my code...",
|
|
169
|
+
)
|
|
170
|
+
yield LoadingIndicator(id="generate_spinner")
|
|
171
|
+
with Horizontal(id="generate_buttons"):
|
|
172
|
+
yield Button("Generate", id="generate_submit", variant="primary")
|
|
173
|
+
yield Button("Back", id="generate_cancel")
|
|
174
|
+
|
|
175
|
+
def action_cancel(self) -> None:
|
|
176
|
+
if self._busy:
|
|
177
|
+
return
|
|
178
|
+
self.dismiss(None)
|
|
179
|
+
|
|
180
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
181
|
+
if event.button.id == "generate_cancel":
|
|
182
|
+
if self._busy:
|
|
183
|
+
return
|
|
184
|
+
self.dismiss(None)
|
|
185
|
+
return
|
|
186
|
+
if event.button.id != "generate_submit":
|
|
187
|
+
return
|
|
188
|
+
description = (self.query_one("#generate_input", TextArea).text or "").strip()
|
|
189
|
+
if not description:
|
|
190
|
+
self._set_error("Please enter a description.")
|
|
191
|
+
return
|
|
192
|
+
app = getattr(self, "app", None)
|
|
193
|
+
if hasattr(app, "start_agent_generation"):
|
|
194
|
+
app.start_agent_generation(description, self)
|
|
195
|
+
|
|
196
|
+
def _set_error(self, message: str) -> None:
|
|
197
|
+
error_widget = self.query_one("#generate_error", Static)
|
|
198
|
+
error_widget.update(message)
|
|
199
|
+
|
|
200
|
+
def clear_error(self) -> None:
|
|
201
|
+
self._set_error("")
|
|
202
|
+
|
|
203
|
+
def set_error(self, message: str) -> None:
|
|
204
|
+
self._set_error(message)
|
|
205
|
+
|
|
206
|
+
def set_status(self, message: str) -> None:
|
|
207
|
+
status_widget = self.query_one("#generate_status", Static)
|
|
208
|
+
status_widget.update(message)
|
|
209
|
+
|
|
210
|
+
def set_busy(self, busy: bool, message: str = "") -> None:
|
|
211
|
+
input_area = self.query_one("#generate_input", TextArea)
|
|
212
|
+
submit_button = self.query_one("#generate_submit", Button)
|
|
213
|
+
cancel_button = self.query_one("#generate_cancel", Button)
|
|
214
|
+
spinner = self.query_one("#generate_spinner", LoadingIndicator)
|
|
215
|
+
self._busy = busy
|
|
216
|
+
input_area.disabled = busy
|
|
217
|
+
submit_button.disabled = busy
|
|
218
|
+
cancel_button.disabled = busy
|
|
219
|
+
spinner.display = busy
|
|
220
|
+
if message:
|
|
221
|
+
self.set_status(message)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class AgentFormScreen(ModalScreen[Optional[AgentFormResult]]):
|
|
225
|
+
"""Modal form for adding/editing agents."""
|
|
226
|
+
|
|
227
|
+
def __init__(
|
|
228
|
+
self,
|
|
229
|
+
mode: str,
|
|
230
|
+
*,
|
|
231
|
+
existing_agent: Optional[AgentDefinition] = None,
|
|
232
|
+
draft: Optional[AgentDraft] = None,
|
|
233
|
+
) -> None:
|
|
234
|
+
super().__init__()
|
|
235
|
+
self._mode = mode
|
|
236
|
+
self._existing_agent = existing_agent
|
|
237
|
+
self._draft = draft
|
|
238
|
+
config = get_global_config()
|
|
239
|
+
pointer_map = config.model_pointers.model_dump()
|
|
240
|
+
self._model_default = (
|
|
241
|
+
(self._existing_agent.model if self._existing_agent else None)
|
|
242
|
+
or (self._draft.model if self._draft else None)
|
|
243
|
+
or pointer_map.get("main", "main")
|
|
244
|
+
)
|
|
245
|
+
self._location_default = (
|
|
246
|
+
self._existing_agent.location
|
|
247
|
+
if self._existing_agent
|
|
248
|
+
else (self._draft.location if self._draft else AgentLocation.USER)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def compose(self) -> ComposeResult:
|
|
252
|
+
title = "Add agent" if self._mode == "add" else "Edit agent"
|
|
253
|
+
with Container(id="form_dialog"):
|
|
254
|
+
yield Static(title, id="form_title")
|
|
255
|
+
yield Static("", id="form_error")
|
|
256
|
+
with VerticalScroll(id="form_fields"):
|
|
257
|
+
if self._mode == "add":
|
|
258
|
+
name_default = self._draft.name if self._draft else ""
|
|
259
|
+
yield Static("Agent name", classes="field_label")
|
|
260
|
+
yield Input(
|
|
261
|
+
value=name_default,
|
|
262
|
+
placeholder="Agent name",
|
|
263
|
+
id="name_input",
|
|
264
|
+
)
|
|
265
|
+
else:
|
|
266
|
+
name_display = self._existing_agent.agent_type if self._existing_agent else ""
|
|
267
|
+
yield Static("Agent name", classes="field_label")
|
|
268
|
+
yield Static(name_display, id="name_static", classes="field_value")
|
|
269
|
+
|
|
270
|
+
description_default = ""
|
|
271
|
+
if self._existing_agent:
|
|
272
|
+
description_default = self._existing_agent.when_to_use
|
|
273
|
+
elif self._draft:
|
|
274
|
+
description_default = self._draft.description
|
|
275
|
+
yield Static("Description (when to use)", classes="field_label")
|
|
276
|
+
yield Input(
|
|
277
|
+
value=description_default,
|
|
278
|
+
placeholder="When should this agent be used?",
|
|
279
|
+
id="description_input",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
tools_default = "*"
|
|
283
|
+
if self._existing_agent:
|
|
284
|
+
tools_default = "*" if "*" in self._existing_agent.tools else ", ".join(
|
|
285
|
+
self._existing_agent.tools
|
|
286
|
+
)
|
|
287
|
+
elif self._draft:
|
|
288
|
+
tools_default = (
|
|
289
|
+
"*"
|
|
290
|
+
if "*" in self._draft.tools
|
|
291
|
+
else ", ".join(self._draft.tools)
|
|
292
|
+
)
|
|
293
|
+
yield Static("Tools", classes="field_label")
|
|
294
|
+
yield Input(
|
|
295
|
+
value=tools_default,
|
|
296
|
+
placeholder="Comma-separated tool names or *",
|
|
297
|
+
id="tools_input",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
yield Static("Model (profile or pointer)", classes="field_label")
|
|
301
|
+
yield Input(
|
|
302
|
+
value=self._model_default,
|
|
303
|
+
placeholder="Model profile or pointer",
|
|
304
|
+
id="model_input",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
location_default = getattr(self._location_default, "value", AgentLocation.USER.value)
|
|
308
|
+
location_options = [
|
|
309
|
+
(AgentLocation.USER.value, AgentLocation.USER.value),
|
|
310
|
+
(AgentLocation.PROJECT.value, AgentLocation.PROJECT.value),
|
|
311
|
+
]
|
|
312
|
+
yield Static("Location", classes="field_label")
|
|
313
|
+
yield Select(location_options, value=location_default, id="location_select")
|
|
314
|
+
|
|
315
|
+
prompt_default = ""
|
|
316
|
+
if self._existing_agent:
|
|
317
|
+
prompt_default = self._existing_agent.system_prompt
|
|
318
|
+
elif self._draft:
|
|
319
|
+
prompt_default = self._draft.system_prompt
|
|
320
|
+
yield Static("System prompt", classes="field_label")
|
|
321
|
+
yield TextArea(
|
|
322
|
+
text=prompt_default,
|
|
323
|
+
placeholder="System prompt",
|
|
324
|
+
id="prompt_input",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
with Horizontal(id="form_buttons"):
|
|
328
|
+
yield Button("Save", id="form_save", variant="primary")
|
|
329
|
+
yield Button("Cancel", id="form_cancel")
|
|
330
|
+
|
|
331
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
332
|
+
if event.button.id == "form_cancel":
|
|
333
|
+
self.dismiss(None)
|
|
334
|
+
return
|
|
335
|
+
if event.button.id != "form_save":
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
name_input = self.query_one("#name_input", Input) if self._mode == "add" else None
|
|
339
|
+
description_input = self.query_one("#description_input", Input)
|
|
340
|
+
tools_input = self.query_one("#tools_input", Input)
|
|
341
|
+
model_input = self.query_one("#model_input", Input)
|
|
342
|
+
location_select = self.query_one("#location_select", Select)
|
|
343
|
+
prompt_input = self.query_one("#prompt_input", TextArea)
|
|
344
|
+
|
|
345
|
+
name = self._existing_agent.agent_type if self._existing_agent else ""
|
|
346
|
+
if self._mode == "add":
|
|
347
|
+
name = (name_input.value or "").strip() if name_input else ""
|
|
348
|
+
if not name:
|
|
349
|
+
self._set_error("Agent name is required.")
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
description = (description_input.value or "").strip()
|
|
353
|
+
if not description:
|
|
354
|
+
self._set_error("Description is required.")
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
tools_raw = (tools_input.value or "").strip() or "*"
|
|
358
|
+
tools = [item.strip() for item in tools_raw.split(",") if item.strip()]
|
|
359
|
+
if not tools:
|
|
360
|
+
tools = ["*"]
|
|
361
|
+
if "*" in tools:
|
|
362
|
+
tools = ["*"]
|
|
363
|
+
|
|
364
|
+
system_prompt = (prompt_input.text or "").strip()
|
|
365
|
+
if not system_prompt:
|
|
366
|
+
self._set_error("System prompt is required.")
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
location_raw = (location_select.value or "").strip().lower()
|
|
370
|
+
location = AgentLocation.PROJECT if location_raw == "project" else AgentLocation.USER
|
|
371
|
+
|
|
372
|
+
model_value = (model_input.value or "").strip()
|
|
373
|
+
model = model_value or self._model_default or None
|
|
374
|
+
|
|
375
|
+
self.dismiss(
|
|
376
|
+
AgentFormResult(
|
|
377
|
+
name=name,
|
|
378
|
+
description=description,
|
|
379
|
+
tools=tools,
|
|
380
|
+
system_prompt=system_prompt,
|
|
381
|
+
location=location,
|
|
382
|
+
model=model,
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
def _set_error(self, message: str) -> None:
|
|
387
|
+
error_widget = self.query_one("#form_error", Static)
|
|
388
|
+
error_widget.update(message)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _extract_assistant_text(message: Any) -> str:
|
|
392
|
+
content = getattr(message, "message", None)
|
|
393
|
+
if content is not None and hasattr(content, "content"):
|
|
394
|
+
content = content.content
|
|
395
|
+
elif hasattr(message, "content"):
|
|
396
|
+
content = message.content
|
|
397
|
+
|
|
398
|
+
if isinstance(content, str):
|
|
399
|
+
return content
|
|
400
|
+
if isinstance(content, list):
|
|
401
|
+
parts = []
|
|
402
|
+
for block in content:
|
|
403
|
+
text = getattr(block, "text", None)
|
|
404
|
+
if not text and isinstance(block, dict):
|
|
405
|
+
text = block.get("text")
|
|
406
|
+
if text:
|
|
407
|
+
parts.append(str(text))
|
|
408
|
+
return "\n".join(parts)
|
|
409
|
+
return ""
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _extract_json_candidate(text: str) -> Optional[str]:
|
|
413
|
+
if not text:
|
|
414
|
+
return None
|
|
415
|
+
match = re.search(r"```json\\s*(.*?)```", text, re.IGNORECASE | re.DOTALL)
|
|
416
|
+
if match:
|
|
417
|
+
return match.group(1).strip()
|
|
418
|
+
match = re.search(r"```\\s*(.*?)```", text, re.DOTALL)
|
|
419
|
+
if match:
|
|
420
|
+
return match.group(1).strip()
|
|
421
|
+
return text.strip()
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _parse_json_from_text(text: str) -> Optional[Any]:
|
|
425
|
+
candidate = _extract_json_candidate(text)
|
|
426
|
+
parsed = safe_parse_json(candidate)
|
|
427
|
+
if parsed is not None:
|
|
428
|
+
return parsed
|
|
429
|
+
if not candidate:
|
|
430
|
+
return None
|
|
431
|
+
start = candidate.find("{")
|
|
432
|
+
end = candidate.rfind("}")
|
|
433
|
+
if start != -1 and end != -1 and end > start:
|
|
434
|
+
return safe_parse_json(candidate[start : end + 1])
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _slugify(value: str) -> str:
|
|
439
|
+
raw = (value or "").lower()
|
|
440
|
+
raw = re.sub(r"[^a-z0-9_-]+", "-", raw)
|
|
441
|
+
raw = re.sub(r"-{2,}", "-", raw).strip("-")
|
|
442
|
+
return raw or "new-agent"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _coerce_agent_draft(data: dict[str, Any], fallback_description: str) -> AgentDraft:
|
|
446
|
+
name_raw = data.get("name") or data.get("agent_name") or data.get("agent")
|
|
447
|
+
description_raw = data.get("description") or data.get("when_to_use") or fallback_description
|
|
448
|
+
tools_raw = data.get("tools") or data.get("tooling") or ["*"]
|
|
449
|
+
system_prompt_raw = data.get("system_prompt") or data.get("prompt") or ""
|
|
450
|
+
model_raw = data.get("model")
|
|
451
|
+
location_raw = data.get("location") or data.get("scope") or "user"
|
|
452
|
+
|
|
453
|
+
name = _slugify(str(name_raw or fallback_description))
|
|
454
|
+
description = str(description_raw or fallback_description).strip() or fallback_description
|
|
455
|
+
|
|
456
|
+
tools: list[str] = []
|
|
457
|
+
if isinstance(tools_raw, str):
|
|
458
|
+
tools = [item.strip() for item in tools_raw.split(",") if item.strip()]
|
|
459
|
+
elif isinstance(tools_raw, list):
|
|
460
|
+
tools = [str(item).strip() for item in tools_raw if str(item).strip()]
|
|
461
|
+
if not tools or "*" in tools:
|
|
462
|
+
tools = ["*"]
|
|
463
|
+
|
|
464
|
+
system_prompt = str(system_prompt_raw or "").strip()
|
|
465
|
+
if not system_prompt:
|
|
466
|
+
system_prompt = (
|
|
467
|
+
"You are a specialized subagent for Ripperdoc. "
|
|
468
|
+
"Follow the user's request and complete tasks autonomously. "
|
|
469
|
+
"Provide a concise report when done."
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
model = str(model_raw).strip() if isinstance(model_raw, str) and model_raw.strip() else None
|
|
473
|
+
location = (
|
|
474
|
+
AgentLocation.PROJECT
|
|
475
|
+
if str(location_raw).strip().lower() == "project"
|
|
476
|
+
else AgentLocation.USER
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
return AgentDraft(
|
|
480
|
+
name=name,
|
|
481
|
+
description=description,
|
|
482
|
+
tools=tools,
|
|
483
|
+
system_prompt=system_prompt,
|
|
484
|
+
location=location,
|
|
485
|
+
model=model,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class AgentsApp(App[None]):
|
|
490
|
+
CSS = """
|
|
491
|
+
#status_bar {
|
|
492
|
+
height: 1;
|
|
493
|
+
color: $text-muted;
|
|
494
|
+
padding: 0 1;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
#body {
|
|
498
|
+
layout: horizontal;
|
|
499
|
+
height: 1fr;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#items_table {
|
|
503
|
+
width: 44%;
|
|
504
|
+
min-width: 40;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
#details_panel {
|
|
508
|
+
width: 56%;
|
|
509
|
+
padding: 0 1;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#form_dialog, #confirm_dialog, #info_dialog, #method_dialog, #generate_dialog {
|
|
513
|
+
width: 76;
|
|
514
|
+
max-height: 90%;
|
|
515
|
+
background: $panel;
|
|
516
|
+
border: round $accent;
|
|
517
|
+
padding: 1 2;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#form_title, #info_title, #method_title, #generate_title {
|
|
521
|
+
text-style: bold;
|
|
522
|
+
padding: 0 0 1 0;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
#form_error, #generate_error {
|
|
526
|
+
color: $error;
|
|
527
|
+
padding: 0 0 1 0;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
#form_fields Input, #form_fields Select, #form_fields TextArea {
|
|
531
|
+
margin: 0 0 1 0;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
#generate_fields TextArea {
|
|
535
|
+
margin: 0 0 1 0;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
#form_fields {
|
|
539
|
+
height: 1fr;
|
|
540
|
+
overflow: auto;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
#generate_fields {
|
|
544
|
+
height: 1fr;
|
|
545
|
+
overflow: auto;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
#prompt_input {
|
|
549
|
+
height: 8;
|
|
550
|
+
min-height: 6;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
#generate_input {
|
|
554
|
+
height: 8;
|
|
555
|
+
min-height: 6;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
.field_label {
|
|
559
|
+
color: $text-muted;
|
|
560
|
+
padding: 0 0 0 0;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
.field_value {
|
|
564
|
+
color: $accent;
|
|
565
|
+
text-style: bold;
|
|
566
|
+
padding: 0 0 1 0;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
#form_buttons, #confirm_buttons, #info_buttons, #method_buttons, #generate_buttons {
|
|
570
|
+
align-horizontal: right;
|
|
571
|
+
padding-top: 1;
|
|
572
|
+
height: auto;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
#confirm_message {
|
|
576
|
+
padding: 0 0 1 0;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
#info_body {
|
|
580
|
+
height: 1fr;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
#method_subtitle, #method_hint, #generate_subtitle {
|
|
584
|
+
color: $text-muted;
|
|
585
|
+
padding: 0 0 1 0;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
#generate_status {
|
|
589
|
+
color: $text-muted;
|
|
590
|
+
padding: 0 0 1 0;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
#generate_spinner {
|
|
594
|
+
height: 1;
|
|
595
|
+
}
|
|
596
|
+
"""
|
|
597
|
+
|
|
598
|
+
BINDINGS = [
|
|
599
|
+
("a", "add", "Add"),
|
|
600
|
+
("e", "edit", "Edit"),
|
|
601
|
+
("d", "delete", "Delete"),
|
|
602
|
+
("t", "toggle_view", "Toggle view"),
|
|
603
|
+
("s", "show", "Show"),
|
|
604
|
+
("c", "cancel_run", "Cancel run"),
|
|
605
|
+
("r", "refresh", "Refresh"),
|
|
606
|
+
("q", "quit", "Quit"),
|
|
607
|
+
]
|
|
608
|
+
|
|
609
|
+
def __init__(self) -> None:
|
|
610
|
+
super().__init__()
|
|
611
|
+
self._view_mode: str = "agents"
|
|
612
|
+
self._row_keys: list[str] = []
|
|
613
|
+
self._selected_key: Optional[str] = None
|
|
614
|
+
self._agent_map: dict[str, AgentDefinition] = {}
|
|
615
|
+
self._run_snapshots: dict[str, dict[str, Any]] = {}
|
|
616
|
+
self._failed_files: list[tuple[str, str]] = []
|
|
617
|
+
self._generation_worker: Optional[Worker[Optional[AgentDraft]]] = None
|
|
618
|
+
self._generate_screen: Optional[AgentGenerateScreen] = None
|
|
619
|
+
|
|
620
|
+
def compose(self) -> ComposeResult:
|
|
621
|
+
yield Header(show_clock=False)
|
|
622
|
+
yield Static("", id="status_bar")
|
|
623
|
+
with Container(id="body"):
|
|
624
|
+
yield DataTable(id="items_table")
|
|
625
|
+
yield Static(id="details_panel")
|
|
626
|
+
yield Footer()
|
|
627
|
+
|
|
628
|
+
def on_mount(self) -> None:
|
|
629
|
+
table = self.query_one("#items_table", DataTable)
|
|
630
|
+
try:
|
|
631
|
+
table.cursor_type = "row"
|
|
632
|
+
table.zebra_stripes = True
|
|
633
|
+
except Exception:
|
|
634
|
+
pass
|
|
635
|
+
self._switch_view("agents", select_first=True)
|
|
636
|
+
|
|
637
|
+
def action_toggle_view(self) -> None:
|
|
638
|
+
next_view = "runs" if self._view_mode == "agents" else "agents"
|
|
639
|
+
self._switch_view(next_view, select_first=True)
|
|
640
|
+
|
|
641
|
+
def action_refresh(self) -> None:
|
|
642
|
+
if self._view_mode == "agents":
|
|
643
|
+
self._refresh_agents(select_first=False)
|
|
644
|
+
else:
|
|
645
|
+
self._refresh_runs(select_first=False)
|
|
646
|
+
self._set_status("Refreshed.")
|
|
647
|
+
|
|
648
|
+
def action_add(self) -> None:
|
|
649
|
+
if self._view_mode != "agents":
|
|
650
|
+
self._set_status("Switch to agents view to add.")
|
|
651
|
+
return
|
|
652
|
+
screen = CreateMethodScreen()
|
|
653
|
+
self.push_screen(screen, self._handle_create_method)
|
|
654
|
+
|
|
655
|
+
def _handle_create_method(self, method: Optional[str]) -> None:
|
|
656
|
+
if method == "manual":
|
|
657
|
+
screen = AgentFormScreen("add")
|
|
658
|
+
self.push_screen(screen, self._handle_add_result)
|
|
659
|
+
return
|
|
660
|
+
if method == "generate":
|
|
661
|
+
screen = AgentGenerateScreen()
|
|
662
|
+
self.push_screen(screen)
|
|
663
|
+
|
|
664
|
+
def start_agent_generation(self, description: str, screen: AgentGenerateScreen) -> None:
|
|
665
|
+
if not description:
|
|
666
|
+
return
|
|
667
|
+
if self._generation_worker and self._generation_worker.state in (
|
|
668
|
+
WorkerState.PENDING,
|
|
669
|
+
WorkerState.RUNNING,
|
|
670
|
+
):
|
|
671
|
+
self._set_status("Generation already in progress.")
|
|
672
|
+
return
|
|
673
|
+
self._generate_screen = screen
|
|
674
|
+
screen.clear_error()
|
|
675
|
+
screen.set_busy(True, "Generating agent configuration...")
|
|
676
|
+
self._set_status("Generating agent configuration...")
|
|
677
|
+
self._generation_worker = self.run_worker(
|
|
678
|
+
self._generate_agent_draft(description),
|
|
679
|
+
name="agent_generate",
|
|
680
|
+
group="agents",
|
|
681
|
+
exit_on_error=False,
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
async def _generate_agent_draft(self, description: str) -> Optional[AgentDraft]:
|
|
685
|
+
tools_list = ", ".join(BUILTIN_TOOL_NAMES)
|
|
686
|
+
system_prompt = (
|
|
687
|
+
"You are an expert at writing Ripperdoc subagent definitions. "
|
|
688
|
+
"Return ONLY a valid JSON object and no extra prose."
|
|
689
|
+
)
|
|
690
|
+
user_prompt = (
|
|
691
|
+
"Create a subagent configuration based on the description below.\n"
|
|
692
|
+
"Return JSON with keys:\n"
|
|
693
|
+
"- name: short lowercase slug (letters, numbers, hyphens)\n"
|
|
694
|
+
"- description: when to use the agent\n"
|
|
695
|
+
"- tools: array of tool names from the allowed list or [\"*\"] for all\n"
|
|
696
|
+
"- system_prompt: detailed instructions for the agent\n"
|
|
697
|
+
"- model: optional model pointer (main/quick or profile name)\n"
|
|
698
|
+
"- location: optional \"user\" or \"project\"\n\n"
|
|
699
|
+
f"Allowed tools: {tools_list}\n\n"
|
|
700
|
+
f"Description: {description}\n"
|
|
701
|
+
)
|
|
702
|
+
assistant = await query_llm(
|
|
703
|
+
[create_user_message(user_prompt)],
|
|
704
|
+
system_prompt,
|
|
705
|
+
[],
|
|
706
|
+
model="quick",
|
|
707
|
+
stream=False,
|
|
708
|
+
)
|
|
709
|
+
response_text = _extract_assistant_text(assistant)
|
|
710
|
+
parsed = _parse_json_from_text(response_text)
|
|
711
|
+
if isinstance(parsed, list) and parsed:
|
|
712
|
+
parsed = parsed[0]
|
|
713
|
+
if not isinstance(parsed, dict):
|
|
714
|
+
return None
|
|
715
|
+
return _coerce_agent_draft(parsed, description)
|
|
716
|
+
|
|
717
|
+
def action_edit(self) -> None:
|
|
718
|
+
if self._view_mode != "agents":
|
|
719
|
+
self._set_status("Switch to agents view to edit.")
|
|
720
|
+
return
|
|
721
|
+
agent = self._selected_agent()
|
|
722
|
+
if not agent:
|
|
723
|
+
self._set_status("No agent selected.")
|
|
724
|
+
return
|
|
725
|
+
if agent.location == AgentLocation.BUILT_IN:
|
|
726
|
+
self._set_status("Built-in agents cannot be edited.")
|
|
727
|
+
return
|
|
728
|
+
screen = AgentFormScreen("edit", existing_agent=agent)
|
|
729
|
+
self.push_screen(screen, self._handle_edit_result)
|
|
730
|
+
|
|
731
|
+
def action_delete(self) -> None:
|
|
732
|
+
if self._view_mode != "agents":
|
|
733
|
+
self._set_status("Switch to agents view to delete.")
|
|
734
|
+
return
|
|
735
|
+
agent = self._selected_agent()
|
|
736
|
+
if not agent:
|
|
737
|
+
self._set_status("No agent selected.")
|
|
738
|
+
return
|
|
739
|
+
if agent.location == AgentLocation.BUILT_IN:
|
|
740
|
+
self._set_status("Built-in agents cannot be deleted.")
|
|
741
|
+
return
|
|
742
|
+
screen = ConfirmScreen(f"Delete agent '{agent.agent_type}'?")
|
|
743
|
+
self.push_screen(screen, self._handle_delete_confirm)
|
|
744
|
+
|
|
745
|
+
def action_show(self) -> None:
|
|
746
|
+
if self._view_mode == "agents":
|
|
747
|
+
agent = self._selected_agent()
|
|
748
|
+
if not agent:
|
|
749
|
+
self._set_status("No agent selected.")
|
|
750
|
+
return
|
|
751
|
+
renderable = self._build_agent_details(agent, full_prompt=True)
|
|
752
|
+
self.push_screen(InfoScreen(f"Agent: {agent.agent_type}", renderable))
|
|
753
|
+
return
|
|
754
|
+
run_id = self._selected_key
|
|
755
|
+
if not run_id:
|
|
756
|
+
self._set_status("No run selected.")
|
|
757
|
+
return
|
|
758
|
+
snapshot = self._run_snapshots.get(run_id) or get_agent_run_snapshot(run_id)
|
|
759
|
+
if not snapshot:
|
|
760
|
+
self._set_status("Run not found.")
|
|
761
|
+
return
|
|
762
|
+
renderable = self._build_run_details(run_id, snapshot)
|
|
763
|
+
self.push_screen(InfoScreen(f"Run: {run_id}", renderable))
|
|
764
|
+
|
|
765
|
+
async def action_cancel_run(self) -> None:
|
|
766
|
+
if self._view_mode != "runs":
|
|
767
|
+
self._set_status("Switch to runs view to cancel.")
|
|
768
|
+
return
|
|
769
|
+
run_id = self._selected_key
|
|
770
|
+
if not run_id:
|
|
771
|
+
self._set_status("No run selected.")
|
|
772
|
+
return
|
|
773
|
+
try:
|
|
774
|
+
cancelled = await cancel_agent_run(run_id)
|
|
775
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
776
|
+
self._set_status(f"Failed to cancel '{run_id}': {exc}")
|
|
777
|
+
return
|
|
778
|
+
if cancelled:
|
|
779
|
+
self._set_status(f"Cancelled subagent {run_id}.")
|
|
780
|
+
else:
|
|
781
|
+
self._set_status(f"No running subagent found for '{run_id}'.")
|
|
782
|
+
self._refresh_runs(select_first=False)
|
|
783
|
+
|
|
784
|
+
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
785
|
+
worker = event.worker
|
|
786
|
+
if worker.name != "agent_generate":
|
|
787
|
+
return
|
|
788
|
+
screen = self._generate_screen
|
|
789
|
+
if event.state == WorkerState.ERROR:
|
|
790
|
+
error = worker.error or "Generation failed."
|
|
791
|
+
self._set_status(f"Generation failed: {error}")
|
|
792
|
+
if screen and screen.is_attached:
|
|
793
|
+
screen.set_busy(False)
|
|
794
|
+
screen.set_status("Generation failed.")
|
|
795
|
+
screen.set_error(str(error))
|
|
796
|
+
return
|
|
797
|
+
if event.state != WorkerState.SUCCESS:
|
|
798
|
+
return
|
|
799
|
+
draft = worker.result
|
|
800
|
+
if not draft:
|
|
801
|
+
self._set_status("Failed to parse generated agent config.")
|
|
802
|
+
if screen and screen.is_attached:
|
|
803
|
+
screen.set_busy(False)
|
|
804
|
+
screen.set_status("Failed to parse generated agent config.")
|
|
805
|
+
screen.set_error("Failed to parse generated agent config.")
|
|
806
|
+
return
|
|
807
|
+
self._set_status("Draft generated. Review and save.")
|
|
808
|
+
if screen and screen.is_attached:
|
|
809
|
+
screen.dismiss(None)
|
|
810
|
+
form_screen = AgentFormScreen("add", draft=draft)
|
|
811
|
+
self.push_screen(form_screen, self._handle_add_result)
|
|
812
|
+
|
|
813
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
814
|
+
self._select_by_index(int(event.cursor_row))
|
|
815
|
+
|
|
816
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
817
|
+
self._select_by_index(int(event.cursor_row))
|
|
818
|
+
if self._view_mode == "agents":
|
|
819
|
+
self.action_edit()
|
|
820
|
+
else:
|
|
821
|
+
self.action_show()
|
|
822
|
+
|
|
823
|
+
def _switch_view(self, mode: str, select_first: bool) -> None:
|
|
824
|
+
if mode not in ("agents", "runs"):
|
|
825
|
+
return
|
|
826
|
+
self._view_mode = mode
|
|
827
|
+
table = self.query_one("#items_table", DataTable)
|
|
828
|
+
try:
|
|
829
|
+
table.clear(columns=True)
|
|
830
|
+
except TypeError:
|
|
831
|
+
table.clear()
|
|
832
|
+
if mode == "agents":
|
|
833
|
+
table.add_columns("#", "Name", "Location", "Model", "Tools")
|
|
834
|
+
self._refresh_agents(select_first=select_first)
|
|
835
|
+
self._set_status("Agents view.")
|
|
836
|
+
else:
|
|
837
|
+
table.add_columns("#", "ID", "Status", "Agent", "Duration", "BG", "Result")
|
|
838
|
+
self._refresh_runs(select_first=select_first)
|
|
839
|
+
self._set_status("Runs view.")
|
|
840
|
+
|
|
841
|
+
def _handle_add_result(self, result: Optional[AgentFormResult]) -> None:
|
|
842
|
+
if not result:
|
|
843
|
+
return
|
|
844
|
+
try:
|
|
845
|
+
save_agent_definition(
|
|
846
|
+
agent_type=result.name,
|
|
847
|
+
description=result.description,
|
|
848
|
+
tools=result.tools,
|
|
849
|
+
system_prompt=result.system_prompt,
|
|
850
|
+
location=result.location,
|
|
851
|
+
model=result.model,
|
|
852
|
+
overwrite=False,
|
|
853
|
+
)
|
|
854
|
+
except FileExistsError as exc:
|
|
855
|
+
self._set_status(str(exc))
|
|
856
|
+
return
|
|
857
|
+
except (OSError, IOError, PermissionError, ValueError) as exc:
|
|
858
|
+
self._set_status(f"Failed to create agent: {exc}")
|
|
859
|
+
return
|
|
860
|
+
|
|
861
|
+
config = get_global_config()
|
|
862
|
+
pointer_map = config.model_pointers.model_dump()
|
|
863
|
+
if result.model and result.model not in config.model_profiles and result.model not in pointer_map:
|
|
864
|
+
self._set_status("Saved agent. Model not found; will fall back to main.")
|
|
865
|
+
else:
|
|
866
|
+
self._set_status(f"Saved agent '{result.name}'.")
|
|
867
|
+
self._selected_key = result.name
|
|
868
|
+
self._refresh_agents(select_first=False)
|
|
869
|
+
|
|
870
|
+
def _handle_edit_result(self, result: Optional[AgentFormResult]) -> None:
|
|
871
|
+
if not result:
|
|
872
|
+
return
|
|
873
|
+
try:
|
|
874
|
+
save_agent_definition(
|
|
875
|
+
agent_type=result.name,
|
|
876
|
+
description=result.description,
|
|
877
|
+
tools=result.tools,
|
|
878
|
+
system_prompt=result.system_prompt,
|
|
879
|
+
location=result.location,
|
|
880
|
+
model=result.model,
|
|
881
|
+
overwrite=True,
|
|
882
|
+
)
|
|
883
|
+
except (OSError, IOError, PermissionError, ValueError) as exc:
|
|
884
|
+
self._set_status(f"Failed to update agent: {exc}")
|
|
885
|
+
return
|
|
886
|
+
|
|
887
|
+
config = get_global_config()
|
|
888
|
+
pointer_map = config.model_pointers.model_dump()
|
|
889
|
+
if result.model and result.model not in config.model_profiles and result.model not in pointer_map:
|
|
890
|
+
self._set_status("Updated agent. Model not found; will fall back to main.")
|
|
891
|
+
else:
|
|
892
|
+
self._set_status(f"Updated agent '{result.name}'.")
|
|
893
|
+
self._selected_key = result.name
|
|
894
|
+
self._refresh_agents(select_first=False)
|
|
895
|
+
|
|
896
|
+
def _handle_delete_confirm(self, confirmed: bool) -> None:
|
|
897
|
+
if not confirmed:
|
|
898
|
+
return
|
|
899
|
+
agent = self._selected_agent()
|
|
900
|
+
if not agent:
|
|
901
|
+
return
|
|
902
|
+
try:
|
|
903
|
+
delete_agent_definition(agent.agent_type, agent.location)
|
|
904
|
+
except FileNotFoundError as exc:
|
|
905
|
+
self._set_status(str(exc))
|
|
906
|
+
return
|
|
907
|
+
except (OSError, IOError, PermissionError, ValueError) as exc:
|
|
908
|
+
self._set_status(f"Failed to delete agent: {exc}")
|
|
909
|
+
return
|
|
910
|
+
self._set_status(f"Deleted agent '{agent.agent_type}'.")
|
|
911
|
+
self._selected_key = None
|
|
912
|
+
self._refresh_agents(select_first=True)
|
|
913
|
+
|
|
914
|
+
def _refresh_agents(self, select_first: bool) -> None:
|
|
915
|
+
table = self.query_one("#items_table", DataTable)
|
|
916
|
+
table.clear(columns=False)
|
|
917
|
+
result = load_agent_definitions()
|
|
918
|
+
self._failed_files = [(str(path), str(error)) for path, error in result.failed_files]
|
|
919
|
+
self._agent_map = {agent.agent_type: agent for agent in result.active_agents}
|
|
920
|
+
self._row_keys = []
|
|
921
|
+
|
|
922
|
+
for idx, agent in enumerate(result.active_agents, start=1):
|
|
923
|
+
self._row_keys.append(agent.agent_type)
|
|
924
|
+
tools_label = "all" if "*" in agent.tools else ", ".join(agent.tools)
|
|
925
|
+
model_label = agent.model or "main (default)"
|
|
926
|
+
table.add_row(
|
|
927
|
+
str(idx),
|
|
928
|
+
agent.agent_type,
|
|
929
|
+
agent.location.value,
|
|
930
|
+
model_label,
|
|
931
|
+
self._shorten(tools_label, 28),
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
if not result.active_agents:
|
|
935
|
+
self._selected_key = None
|
|
936
|
+
self._update_details()
|
|
937
|
+
return
|
|
938
|
+
|
|
939
|
+
if self._selected_key and self._selected_key in self._agent_map:
|
|
940
|
+
try:
|
|
941
|
+
row_index = self._row_keys.index(self._selected_key)
|
|
942
|
+
except ValueError:
|
|
943
|
+
row_index = 0
|
|
944
|
+
self._move_cursor(table, row_index)
|
|
945
|
+
self._update_details()
|
|
946
|
+
return
|
|
947
|
+
|
|
948
|
+
if select_first:
|
|
949
|
+
self._selected_key = self._row_keys[0]
|
|
950
|
+
self._move_cursor(table, 0)
|
|
951
|
+
self._update_details()
|
|
952
|
+
|
|
953
|
+
def _refresh_runs(self, select_first: bool) -> None:
|
|
954
|
+
table = self.query_one("#items_table", DataTable)
|
|
955
|
+
table.clear(columns=False)
|
|
956
|
+
self._row_keys = []
|
|
957
|
+
self._run_snapshots = {}
|
|
958
|
+
|
|
959
|
+
run_ids = list_agent_runs()
|
|
960
|
+
for idx, run_id in enumerate(sorted(run_ids), start=1):
|
|
961
|
+
snapshot = get_agent_run_snapshot(run_id) or {}
|
|
962
|
+
self._run_snapshots[run_id] = snapshot
|
|
963
|
+
result_text = snapshot.get("result_text") or snapshot.get("error") or ""
|
|
964
|
+
result_preview = self._shorten(result_text, 60)
|
|
965
|
+
table.add_row(
|
|
966
|
+
str(idx),
|
|
967
|
+
str(run_id),
|
|
968
|
+
str(snapshot.get("status") or "unknown"),
|
|
969
|
+
str(snapshot.get("agent_type") or "unknown"),
|
|
970
|
+
self._format_duration(snapshot.get("duration_ms")),
|
|
971
|
+
"yes" if snapshot.get("is_background") else "no",
|
|
972
|
+
result_preview,
|
|
973
|
+
)
|
|
974
|
+
self._row_keys.append(run_id)
|
|
975
|
+
|
|
976
|
+
if not run_ids:
|
|
977
|
+
self._selected_key = None
|
|
978
|
+
self._update_details()
|
|
979
|
+
return
|
|
980
|
+
|
|
981
|
+
if self._selected_key and self._selected_key in self._run_snapshots:
|
|
982
|
+
try:
|
|
983
|
+
row_index = self._row_keys.index(self._selected_key)
|
|
984
|
+
except ValueError:
|
|
985
|
+
row_index = 0
|
|
986
|
+
self._move_cursor(table, row_index)
|
|
987
|
+
self._update_details()
|
|
988
|
+
return
|
|
989
|
+
|
|
990
|
+
if select_first:
|
|
991
|
+
self._selected_key = self._row_keys[0]
|
|
992
|
+
self._move_cursor(table, 0)
|
|
993
|
+
self._update_details()
|
|
994
|
+
|
|
995
|
+
def _select_by_index(self, row_index: int) -> None:
|
|
996
|
+
if row_index < 0 or row_index >= len(self._row_keys):
|
|
997
|
+
return
|
|
998
|
+
self._selected_key = self._row_keys[row_index]
|
|
999
|
+
self._update_details()
|
|
1000
|
+
|
|
1001
|
+
def _move_cursor(self, table: DataTable, row_index: int) -> None:
|
|
1002
|
+
try:
|
|
1003
|
+
table.move_cursor(row=row_index)
|
|
1004
|
+
except TypeError:
|
|
1005
|
+
try:
|
|
1006
|
+
table.move_cursor(row_index, 0)
|
|
1007
|
+
except Exception:
|
|
1008
|
+
pass
|
|
1009
|
+
except Exception:
|
|
1010
|
+
pass
|
|
1011
|
+
|
|
1012
|
+
def _selected_agent(self) -> Optional[AgentDefinition]:
|
|
1013
|
+
if not self._selected_key:
|
|
1014
|
+
return None
|
|
1015
|
+
return self._agent_map.get(self._selected_key)
|
|
1016
|
+
|
|
1017
|
+
def _update_details(self) -> None:
|
|
1018
|
+
details = self.query_one("#details_panel", Static)
|
|
1019
|
+
if self._view_mode == "agents":
|
|
1020
|
+
agent = self._selected_agent()
|
|
1021
|
+
if not agent:
|
|
1022
|
+
if not self._agent_map:
|
|
1023
|
+
details.update("No agents configured.")
|
|
1024
|
+
else:
|
|
1025
|
+
details.update("No agent selected.")
|
|
1026
|
+
return
|
|
1027
|
+
details.update(self._build_agent_details(agent, full_prompt=False))
|
|
1028
|
+
return
|
|
1029
|
+
|
|
1030
|
+
if not self._selected_key:
|
|
1031
|
+
if not self._run_snapshots:
|
|
1032
|
+
details.update("No subagent runs recorded.")
|
|
1033
|
+
else:
|
|
1034
|
+
details.update("No run selected.")
|
|
1035
|
+
return
|
|
1036
|
+
snapshot = self._run_snapshots.get(self._selected_key)
|
|
1037
|
+
if not snapshot:
|
|
1038
|
+
details.update("No run selected.")
|
|
1039
|
+
return
|
|
1040
|
+
details.update(self._build_run_details(self._selected_key, snapshot, preview=True))
|
|
1041
|
+
|
|
1042
|
+
def _build_agent_details(self, agent: AgentDefinition, *, full_prompt: bool) -> Group:
|
|
1043
|
+
tools_label = "all tools" if "*" in agent.tools else ", ".join(agent.tools)
|
|
1044
|
+
table = Table.grid(padding=(0, 2))
|
|
1045
|
+
table.add_column(style="cyan", no_wrap=True)
|
|
1046
|
+
table.add_column()
|
|
1047
|
+
table.add_row("Agent", escape(agent.agent_type))
|
|
1048
|
+
table.add_row("Location", escape(agent.location.value))
|
|
1049
|
+
table.add_row("Model", escape(agent.model or "main (default)"))
|
|
1050
|
+
table.add_row("Tools", escape(tools_label))
|
|
1051
|
+
table.add_row("Fork context", "yes" if agent.fork_context else "no")
|
|
1052
|
+
if agent.color:
|
|
1053
|
+
table.add_row("Color", escape(agent.color))
|
|
1054
|
+
table.add_row("Description", escape(agent.when_to_use))
|
|
1055
|
+
|
|
1056
|
+
prompt_text = agent.system_prompt or "(empty)"
|
|
1057
|
+
if not full_prompt:
|
|
1058
|
+
prompt_text = self._shorten(prompt_text, 260)
|
|
1059
|
+
prompt_panel = Panel(
|
|
1060
|
+
Text(prompt_text, overflow="fold"),
|
|
1061
|
+
title="System prompt",
|
|
1062
|
+
box=box.SIMPLE,
|
|
1063
|
+
padding=(1, 2),
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
return Group(
|
|
1067
|
+
Panel(table, title=f"Agent: {escape(agent.agent_type)}", box=box.ROUNDED),
|
|
1068
|
+
prompt_panel,
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
def _build_run_details(self, run_id: str, snapshot: dict[str, Any], preview: bool = False) -> Group:
|
|
1072
|
+
details = Table(box=box.SIMPLE_HEAVY, show_header=False)
|
|
1073
|
+
details.add_row("ID", escape(run_id))
|
|
1074
|
+
details.add_row("Status", escape(str(snapshot.get("status") or "unknown")))
|
|
1075
|
+
details.add_row("Agent", escape(str(snapshot.get("agent_type") or "unknown")))
|
|
1076
|
+
details.add_row("Duration", self._format_duration(snapshot.get("duration_ms")))
|
|
1077
|
+
details.add_row("Background", "yes" if snapshot.get("is_background") else "no")
|
|
1078
|
+
if snapshot.get("model_used"):
|
|
1079
|
+
details.add_row("Model", escape(str(snapshot.get("model_used"))))
|
|
1080
|
+
if snapshot.get("tool_use_count"):
|
|
1081
|
+
details.add_row("Tool uses", str(snapshot.get("tool_use_count")))
|
|
1082
|
+
if snapshot.get("missing_tools"):
|
|
1083
|
+
details.add_row("Missing tools", escape(", ".join(snapshot["missing_tools"])))
|
|
1084
|
+
if snapshot.get("error"):
|
|
1085
|
+
details.add_row("Error", escape(str(snapshot.get("error"))))
|
|
1086
|
+
|
|
1087
|
+
result_text = snapshot.get("result_text") or snapshot.get("error") or ""
|
|
1088
|
+
if preview:
|
|
1089
|
+
result_text = self._shorten(result_text, 320)
|
|
1090
|
+
result_panel = Panel(
|
|
1091
|
+
Text(result_text or "(no result)", overflow="fold"),
|
|
1092
|
+
title="Result",
|
|
1093
|
+
box=box.SIMPLE,
|
|
1094
|
+
padding=(1, 2),
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
return Group(
|
|
1098
|
+
Panel(details, title=f"Run: {escape(run_id)}", box=box.ROUNDED, padding=(1, 2)),
|
|
1099
|
+
result_panel,
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
def _set_status(self, message: str) -> None:
|
|
1103
|
+
status = self.query_one("#status_bar", Static)
|
|
1104
|
+
status.update(message)
|
|
1105
|
+
|
|
1106
|
+
@staticmethod
|
|
1107
|
+
def _shorten(text: str, limit: int) -> str:
|
|
1108
|
+
if len(text) <= limit:
|
|
1109
|
+
return text
|
|
1110
|
+
return text[: max(0, limit - 3)] + "..."
|
|
1111
|
+
|
|
1112
|
+
@staticmethod
|
|
1113
|
+
def _format_duration(duration_ms: float | None) -> str:
|
|
1114
|
+
if duration_ms is None:
|
|
1115
|
+
return "-"
|
|
1116
|
+
try:
|
|
1117
|
+
duration = float(duration_ms)
|
|
1118
|
+
except (TypeError, ValueError):
|
|
1119
|
+
return "-"
|
|
1120
|
+
if duration < 1000:
|
|
1121
|
+
return f"{int(duration)} ms"
|
|
1122
|
+
seconds = duration / 1000.0
|
|
1123
|
+
if seconds < 60:
|
|
1124
|
+
return f"{seconds:.1f}s"
|
|
1125
|
+
minutes, secs = divmod(int(seconds), 60)
|
|
1126
|
+
if minutes < 60:
|
|
1127
|
+
return f"{minutes}m {secs}s"
|
|
1128
|
+
hours, mins = divmod(minutes, 60)
|
|
1129
|
+
return f"{hours}h {mins}m"
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def run_agents_tui(on_exit: Optional[Callable[[], Any]] = None) -> bool:
|
|
1133
|
+
"""Run the Textual agents TUI."""
|
|
1134
|
+
app = AgentsApp()
|
|
1135
|
+
app.run()
|
|
1136
|
+
if on_exit:
|
|
1137
|
+
on_exit()
|
|
1138
|
+
return True
|