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.
Files changed (40) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +9 -1
  3. ripperdoc/cli/commands/agents_cmd.py +93 -53
  4. ripperdoc/cli/commands/mcp_cmd.py +3 -0
  5. ripperdoc/cli/commands/models_cmd.py +768 -283
  6. ripperdoc/cli/commands/permissions_cmd.py +107 -52
  7. ripperdoc/cli/commands/resume_cmd.py +61 -51
  8. ripperdoc/cli/commands/themes_cmd.py +31 -1
  9. ripperdoc/cli/ui/agents_tui/__init__.py +3 -0
  10. ripperdoc/cli/ui/agents_tui/textual_app.py +1138 -0
  11. ripperdoc/cli/ui/choice.py +376 -0
  12. ripperdoc/cli/ui/interrupt_listener.py +233 -0
  13. ripperdoc/cli/ui/message_display.py +7 -0
  14. ripperdoc/cli/ui/models_tui/__init__.py +5 -0
  15. ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
  16. ripperdoc/cli/ui/panels.py +19 -4
  17. ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
  18. ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
  19. ripperdoc/cli/ui/provider_options.py +220 -80
  20. ripperdoc/cli/ui/rich_ui.py +91 -83
  21. ripperdoc/cli/ui/tips.py +89 -0
  22. ripperdoc/cli/ui/wizard.py +98 -45
  23. ripperdoc/core/config.py +3 -0
  24. ripperdoc/core/permissions.py +66 -104
  25. ripperdoc/core/providers/anthropic.py +11 -0
  26. ripperdoc/protocol/stdio.py +3 -1
  27. ripperdoc/tools/bash_tool.py +2 -0
  28. ripperdoc/tools/file_edit_tool.py +100 -181
  29. ripperdoc/tools/file_read_tool.py +101 -25
  30. ripperdoc/tools/multi_edit_tool.py +239 -91
  31. ripperdoc/tools/notebook_edit_tool.py +11 -29
  32. ripperdoc/utils/file_editing.py +164 -0
  33. ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
  34. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
  35. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +39 -30
  36. ripperdoc/cli/ui/interrupt_handler.py +0 -208
  37. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
  38. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
  39. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
  40. {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