ellements 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. ellements/__init__.py +57 -0
  2. ellements/agents/__init__.py +45 -0
  3. ellements/agents/backend.py +100 -0
  4. ellements/agents/builder.py +303 -0
  5. ellements/agents/claude_backend.py +200 -0
  6. ellements/agents/controller.py +237 -0
  7. ellements/agents/openai_backend.py +187 -0
  8. ellements/agents/py.typed +0 -0
  9. ellements/agents/runner.py +358 -0
  10. ellements/agents/tools.py +30 -0
  11. ellements/benchmarking/__init__.py +52 -0
  12. ellements/benchmarking/harness.py +342 -0
  13. ellements/benchmarking/py.typed +0 -0
  14. ellements/benchmarking/results.py +173 -0
  15. ellements/cli/__init__.py +80 -0
  16. ellements/cli/adapters.py +73 -0
  17. ellements/cli/agent_tui.py +1178 -0
  18. ellements/cli/components.py +411 -0
  19. ellements/cli/printer.py +229 -0
  20. ellements/cli/py.typed +0 -0
  21. ellements/core/__init__.py +112 -0
  22. ellements/core/async_utils.py +42 -0
  23. ellements/core/budgeting/__init__.py +43 -0
  24. ellements/core/budgeting/client.py +276 -0
  25. ellements/core/budgeting/protocol.py +51 -0
  26. ellements/core/budgeting/trackers.py +177 -0
  27. ellements/core/caching/__init__.py +51 -0
  28. ellements/core/caching/cache.py +73 -0
  29. ellements/core/caching/client.py +300 -0
  30. ellements/core/caching/disk.py +128 -0
  31. ellements/core/caching/keys.py +97 -0
  32. ellements/core/caching/memory.py +104 -0
  33. ellements/core/chunking.py +262 -0
  34. ellements/core/config.py +180 -0
  35. ellements/core/exceptions.py +145 -0
  36. ellements/core/llm/__init__.py +46 -0
  37. ellements/core/llm/client.py +1124 -0
  38. ellements/core/llm/images.py +226 -0
  39. ellements/core/llm/messages.py +202 -0
  40. ellements/core/llm/model_params.py +66 -0
  41. ellements/core/llm/protocol.py +146 -0
  42. ellements/core/llm/requests.py +100 -0
  43. ellements/core/llm/structured.py +91 -0
  44. ellements/core/llm/wrapper.py +79 -0
  45. ellements/core/observability/__init__.py +39 -0
  46. ellements/core/observability/events.py +169 -0
  47. ellements/core/observability/jsonl_logger.py +244 -0
  48. ellements/core/observability/markdown_formatter.py +197 -0
  49. ellements/core/observability/observer.py +56 -0
  50. ellements/core/prompting/__init__.py +14 -0
  51. ellements/core/prompting/context.py +185 -0
  52. ellements/core/prompting/guideline.py +133 -0
  53. ellements/core/prompting/persona.py +267 -0
  54. ellements/core/prompting/sources.py +92 -0
  55. ellements/core/py.typed +0 -0
  56. ellements/core/rate_limit/__init__.py +44 -0
  57. ellements/core/rate_limit/bucket.py +85 -0
  58. ellements/core/rate_limit/client.py +216 -0
  59. ellements/core/rate_limit/protocol.py +27 -0
  60. ellements/core/templating.py +126 -0
  61. ellements/core/tools/__init__.py +51 -0
  62. ellements/core/tools/dialects.py +136 -0
  63. ellements/core/tools/executor.py +48 -0
  64. ellements/core/tools/protocol.py +36 -0
  65. ellements/core/tools/records.py +28 -0
  66. ellements/core/tools/registry.py +205 -0
  67. ellements/core/tools/schemas.py +80 -0
  68. ellements/core/tools/simple.py +119 -0
  69. ellements/core/tools/spec.py +33 -0
  70. ellements/domain_specific/__init__.py +7 -0
  71. ellements/domain_specific/finance/__init__.py +188 -0
  72. ellements/domain_specific/finance/calculations.py +837 -0
  73. ellements/domain_specific/finance/charts.py +426 -0
  74. ellements/domain_specific/finance/portfolio.py +129 -0
  75. ellements/domain_specific/finance/quant_analysis.py +279 -0
  76. ellements/domain_specific/finance/risk.py +362 -0
  77. ellements/domain_specific/finance/technical_indicators.py +241 -0
  78. ellements/domain_specific/finance/tools.py +483 -0
  79. ellements/domain_specific/finance/valuation.py +312 -0
  80. ellements/domain_specific/finance/yahoo_finance.py +1523 -0
  81. ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
  82. ellements/domain_specific/py.typed +0 -0
  83. ellements/execution/__init__.py +56 -0
  84. ellements/execution/callbacks.py +149 -0
  85. ellements/execution/catalog.py +70 -0
  86. ellements/execution/collaborative.py +191 -0
  87. ellements/execution/config.py +135 -0
  88. ellements/execution/py.typed +0 -0
  89. ellements/execution/reflection.py +156 -0
  90. ellements/execution/self_consistency.py +189 -0
  91. ellements/execution/single_call.py +48 -0
  92. ellements/execution/strategies.py +237 -0
  93. ellements/execution/tree_of_thought.py +541 -0
  94. ellements/fslm/__init__.py +108 -0
  95. ellements/fslm/builtins.py +103 -0
  96. ellements/fslm/cli.py +199 -0
  97. ellements/fslm/context.py +50 -0
  98. ellements/fslm/definition.py +163 -0
  99. ellements/fslm/det.py +173 -0
  100. ellements/fslm/dsl.py +458 -0
  101. ellements/fslm/errors.py +38 -0
  102. ellements/fslm/evaluators.py +141 -0
  103. ellements/fslm/kernel.py +603 -0
  104. ellements/fslm/loading.py +123 -0
  105. ellements/fslm/models.py +623 -0
  106. ellements/fslm/nl.py +87 -0
  107. ellements/fslm/observers.py +107 -0
  108. ellements/fslm/persistence.py +72 -0
  109. ellements/fslm/py.typed +0 -0
  110. ellements/fslm/rendering.py +551 -0
  111. ellements/fslm/visualization.py +25 -0
  112. ellements/reporting/__init__.py +12 -0
  113. ellements/reporting/charts.py +364 -0
  114. ellements/reporting/html_generation.py +132 -0
  115. ellements/reporting/py.typed +0 -0
  116. ellements/reporting/visualization.py +73 -0
  117. ellements/standard_tools/__init__.py +22 -0
  118. ellements/standard_tools/py.typed +0 -0
  119. ellements/standard_tools/terminal.py +205 -0
  120. ellements/standard_tools/web/__init__.py +37 -0
  121. ellements/standard_tools/web/browser_viewer.py +214 -0
  122. ellements/standard_tools/web/crawler.py +454 -0
  123. ellements/standard_tools/web/search.py +399 -0
  124. ellements/standard_tools/web/youtube.py +1297 -0
  125. ellements-0.2.0.dist-info/METADATA +368 -0
  126. ellements-0.2.0.dist-info/RECORD +130 -0
  127. ellements-0.2.0.dist-info/WHEEL +5 -0
  128. ellements-0.2.0.dist-info/entry_points.txt +2 -0
  129. ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
  130. ellements-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1178 @@
1
+ """Generic Textual TUI for agent interactions.
2
+
3
+ This is intentionally *application-agnostic*: nothing about the runner,
4
+ the persona/guideline source, the save destination, or any custom
5
+ visualization is hardcoded. Callers wire in their own implementations
6
+ via the four plug-in protocols:
7
+
8
+ - :class:`AgentRunner`: how a query becomes a response.
9
+ - :class:`SaveHandler`: how a response is persisted.
10
+ - :class:`PersonaProvider` / :class:`GuidelineProvider`: optional
11
+ identity/style switching.
12
+
13
+ The runner receives a :class:`TuiControls` handle for emitting
14
+ incremental updates (progress messages, stat snapshots, custom-widget
15
+ payloads) and checking for cancellation. Modal UI state (idle,
16
+ awaiting filename, awaiting persona selection, awaiting guideline
17
+ selection) is tracked through a single :class:`Mode` enum — no scattered
18
+ boolean flags.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import contextlib
25
+ import inspect
26
+ import re
27
+ from collections.abc import Mapping
28
+ from dataclasses import dataclass, field
29
+ from datetime import datetime
30
+ from enum import Enum
31
+ from pathlib import Path
32
+ from typing import Any, Protocol, runtime_checkable
33
+
34
+ from rich.markdown import Markdown
35
+ from rich.panel import Panel
36
+ from textual import work
37
+ from textual.app import App, ComposeResult
38
+ from textual.binding import Binding
39
+ from textual.containers import Container, Horizontal, VerticalScroll
40
+ from textual.screen import Screen
41
+ from textual.suggester import Suggester
42
+ from textual.widgets import (
43
+ Button,
44
+ Footer,
45
+ Header,
46
+ Input,
47
+ Label,
48
+ OptionList,
49
+ RichLog,
50
+ Static,
51
+ )
52
+ from textual.widgets.option_list import Option
53
+
54
+ # ── Plug-in protocols ───────────────────────────────────────────────
55
+
56
+
57
+ @runtime_checkable
58
+ class SaveHandler(Protocol):
59
+ """Persists a response to wherever the embedding app wants it."""
60
+
61
+ def suggest_filename(self, query: str | None, response: str) -> str:
62
+ """Return a default filename to pre-fill the save prompt."""
63
+
64
+ def save(self, content: str, filename: str) -> Path:
65
+ """Persist *content* and return the final on-disk path."""
66
+
67
+
68
+ @runtime_checkable
69
+ class PersonaProvider(Protocol):
70
+ """Adapts a persona library to the TUI's selection model."""
71
+
72
+ def list_ids(self) -> list[str]: ...
73
+ def current_id(self) -> str | None: ...
74
+ def select(self, persona_id: str) -> None: ...
75
+
76
+
77
+ @runtime_checkable
78
+ class GuidelineProvider(Protocol):
79
+ """Adapts a guideline library to the TUI's selection model."""
80
+
81
+ def list_ids(self) -> list[str]: ...
82
+ def current_id(self) -> str | None: ...
83
+ def select(self, guideline_id: str) -> None: ...
84
+ def clear(self) -> None: ...
85
+
86
+
87
+ @runtime_checkable
88
+ class AgentRunner(Protocol):
89
+ """Executes a single query and returns a structured result.
90
+
91
+ Implementations may emit incremental progress/stats/widget updates
92
+ through :class:`TuiControls`. They should periodically check
93
+ ``controls.is_cancelled()`` and raise :class:`asyncio.CancelledError`
94
+ when set.
95
+ """
96
+
97
+ async def run(self, query: str, controls: TuiControls) -> RunResult: ...
98
+
99
+
100
+ @dataclass(slots=True)
101
+ class RunResult:
102
+ """The outcome of an :meth:`AgentRunner.run` invocation."""
103
+
104
+ text: str
105
+ stats: Mapping[str, Any] | None = None
106
+
107
+
108
+ # ── Mode enum (replaces scattered boolean modal flags) ──────────────
109
+
110
+
111
+ class Mode(Enum):
112
+ """Modal input state for the TUI."""
113
+
114
+ IDLE = "idle"
115
+ AWAITING_FILENAME = "awaiting_filename"
116
+ AWAITING_PERSONA = "awaiting_persona"
117
+ AWAITING_GUIDELINE = "awaiting_guideline"
118
+
119
+
120
+ # ── Default SaveHandler ─────────────────────────────────────────────
121
+
122
+
123
+ class FileSaveHandler:
124
+ """Default :class:`SaveHandler` writing Markdown files under *base_dir*.
125
+
126
+ Args:
127
+ base_dir: Directory to write into. Created if missing.
128
+ prefix: String prepended to auto-generated filenames.
129
+ """
130
+
131
+ def __init__(
132
+ self, base_dir: Path | str = ".", *, prefix: str = "response"
133
+ ) -> None:
134
+ self._base_dir = Path(base_dir)
135
+ self._prefix = prefix
136
+
137
+ def suggest_filename(self, query: str | None, response: str) -> str:
138
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
139
+ if query:
140
+ slug = re.sub(r"[^\w\s]", "", query).strip().split()[:3]
141
+ if slug:
142
+ return f"{'_'.join(slug).lower()}_{timestamp}.md"
143
+ return f"{self._prefix}_{timestamp}.md"
144
+
145
+ def save(self, content: str, filename: str) -> Path:
146
+ if not filename.endswith(".md"):
147
+ filename = f"{filename}.md"
148
+ self._base_dir.mkdir(parents=True, exist_ok=True)
149
+ path = (self._base_dir / filename).resolve()
150
+ path.write_text(content, encoding="utf-8")
151
+ return path
152
+
153
+
154
+
155
+ # ── Slash commands ──────────────────────────────────────────────────
156
+
157
+
158
+ @dataclass(slots=True, frozen=True)
159
+ class SlashCommand:
160
+ """One slash command shown in the command menu."""
161
+
162
+ name: str
163
+ description: str
164
+ requires_persona: bool = False
165
+ requires_guideline: bool = False
166
+ requires_custom_widget: bool = False
167
+
168
+
169
+ BUILTIN_COMMANDS: tuple[SlashCommand, ...] = (
170
+ SlashCommand("/help", "Show help and available commands"),
171
+ SlashCommand("/cancel", "Cancel the currently running agent"),
172
+ SlashCommand("/clear", "Clear the output panel"),
173
+ SlashCommand("/reset", "Reset the entire session"),
174
+ SlashCommand("/save", "Save last response to a file"),
175
+ SlashCommand("/persona", "Switch user persona", requires_persona=True),
176
+ SlashCommand(
177
+ "/guideline",
178
+ "Select guideline to refine prompt",
179
+ requires_guideline=True,
180
+ ),
181
+ SlashCommand(
182
+ "/viz",
183
+ "Toggle custom visualization panel",
184
+ requires_custom_widget=True,
185
+ ),
186
+ SlashCommand("/stats", "Toggle statistics panel"),
187
+ SlashCommand("/quit", "Exit the application"),
188
+ )
189
+
190
+
191
+ # ── TuiConfig ───────────────────────────────────────────────────────
192
+
193
+
194
+ @dataclass(slots=True)
195
+ class TuiConfig:
196
+ """Wiring for :class:`AgentTUI`."""
197
+
198
+ agent_name: str
199
+ agent_description: str
200
+ runner: AgentRunner
201
+
202
+ save_handler: SaveHandler = field(default_factory=FileSaveHandler)
203
+ persona_provider: PersonaProvider | None = None
204
+ guideline_provider: GuidelineProvider | None = None
205
+ custom_widget: Any | None = None
206
+ custom_widget_title: str = "Visualization"
207
+ theme: str = "monokai"
208
+ initial_stats: Mapping[str, Any] = field(default_factory=dict)
209
+
210
+
211
+ # ── TuiControls (handed to the runner) ──────────────────────────────
212
+
213
+
214
+ class TuiControls:
215
+ """Callbacks the runner uses to push updates and check cancellation."""
216
+
217
+ def __init__(self, app: AgentTUI) -> None:
218
+ self._app = app
219
+
220
+ def progress(self, message: str) -> None:
221
+ """Emit a progress line to the output panel (thread-safe)."""
222
+ self._app._post_progress(message)
223
+
224
+ def update_stats(self, stats: Mapping[str, Any]) -> None:
225
+ """Replace the current stats snapshot (thread-safe)."""
226
+ self._app._post_stats(stats)
227
+
228
+ def update_custom_widget(self, payload: Any) -> None:
229
+ """Route *payload* to the custom widget's ``handle_payload`` (if any).
230
+
231
+ Implementations whose custom widget implements
232
+ ``handle_payload(payload)`` will receive it; otherwise this is a
233
+ no-op.
234
+ """
235
+ self._app._post_custom_widget(payload)
236
+
237
+ def is_cancelled(self) -> bool:
238
+ return self._app._cancel_requested
239
+
240
+
241
+ # ── Slash command suggester ─────────────────────────────────────────
242
+
243
+
244
+ class _SlashSuggester(Suggester): # type: ignore[misc]
245
+ def __init__(self, commands: list[str]) -> None:
246
+ super().__init__(use_cache=False, case_sensitive=False)
247
+ self._commands = commands
248
+
249
+ async def get_suggestion(self, value: str) -> str | None:
250
+ if not value.startswith("/"):
251
+ return None
252
+ for cmd in self._commands:
253
+ if cmd.startswith(value.lower()):
254
+ return cmd
255
+ return None
256
+
257
+
258
+ # ── Command menu overlay ────────────────────────────────────────────
259
+
260
+
261
+ class _CommandMenuOverlay(Container): # type: ignore[misc]
262
+ def __init__(self, commands: list[SlashCommand], **kwargs: Any) -> None:
263
+ super().__init__(**kwargs)
264
+ self.commands = commands
265
+
266
+ def compose(self) -> ComposeResult:
267
+ yield Label(
268
+ "[bold cyan]Available Commands[/bold cyan]",
269
+ id="command-menu-title",
270
+ )
271
+ yield OptionList(
272
+ *[
273
+ Option(
274
+ f"{c.name} [dim]{c.description}[/dim]", id=c.name
275
+ )
276
+ for c in self.commands
277
+ ],
278
+ id="command-menu-list",
279
+ )
280
+
281
+
282
+ # ── AgentTUI ────────────────────────────────────────────────────────
283
+
284
+
285
+ class AgentTUI(App[None]): # type: ignore[misc]
286
+ """Application-agnostic Textual TUI for any :class:`AgentRunner`."""
287
+
288
+ CSS = """
289
+ Screen { background: $surface; }
290
+ #title-bar {
291
+ background: $surface; color: $text; height: 1;
292
+ content-align: left middle; padding: 0 2; text-style: bold;
293
+ }
294
+ #agent-info { display: none; }
295
+ #main-container {
296
+ height: 1fr; background: $surface;
297
+ border: solid $primary; margin: 0 1;
298
+ }
299
+ #output-panel {
300
+ border: none; height: 1fr; background: $surface; padding: 1 2;
301
+ }
302
+ #input-container {
303
+ height: auto; background: $surface; padding: 1 2;
304
+ border-top: solid $primary;
305
+ }
306
+ #status-bar {
307
+ height: 1; background: $surface; color: $text-muted;
308
+ padding: 0 2; text-style: dim;
309
+ }
310
+ Input { border: solid $primary; background: $surface; color: $text; }
311
+ Input:focus { border: solid $accent; }
312
+ Button { margin: 0 1; background: $surface; color: $text; border: solid $primary; }
313
+ Button:hover { background: $primary; border: solid $accent; }
314
+ Button.-primary { background: $primary; border: solid $primary; }
315
+ Button.-primary:hover { background: $accent; border: solid $accent; }
316
+ _CommandMenuOverlay {
317
+ display: none; layer: overlay; offset: 2 2;
318
+ width: 60; height: auto; max-height: 15;
319
+ background: $panel; border: thick $accent; padding: 0;
320
+ }
321
+ _CommandMenuOverlay.visible { display: block; }
322
+ #command-menu-title {
323
+ width: 100%; height: auto; padding: 0 1;
324
+ background: $boost; color: $text; text-style: bold;
325
+ }
326
+ #command-menu-list {
327
+ width: 100%; height: auto; background: $panel;
328
+ border: none; padding: 0 1;
329
+ }
330
+ #stats-panel {
331
+ width: 30; height: 1fr; background: $surface;
332
+ border-left: solid $primary; padding: 1 2; display: none;
333
+ }
334
+ #stats-panel.visible { display: block; }
335
+ #stats-title {
336
+ text-style: bold; color: $accent; margin-bottom: 1;
337
+ }
338
+ #custom-panel {
339
+ width: 50; height: 1fr; background: $surface;
340
+ border-left: solid $primary; padding: 1 2; display: none;
341
+ }
342
+ #custom-panel.visible { display: block; }
343
+ #custom-title { text-style: bold; color: $accent; margin-bottom: 1; }
344
+ #content-area { width: 1fr; height: 1fr; }
345
+ """
346
+
347
+ BINDINGS = [
348
+ Binding("ctrl+c", "quit", "Quit", show=True),
349
+ Binding("ctrl+x", "cancel", "Cancel", show=True),
350
+ Binding("ctrl+l", "clear", "Clear", show=True),
351
+ Binding("ctrl+r", "reset", "Reset", show=True),
352
+ Binding("ctrl+s", "save", "Save", show=True),
353
+ ]
354
+
355
+ def __init__(self, config: TuiConfig, **kwargs: Any) -> None:
356
+ super().__init__(**kwargs)
357
+ self._config = config
358
+ self._mode: Mode = Mode.IDLE
359
+ self._mode_payload: Any = None
360
+
361
+ self._query_history: list[str] = []
362
+ self._last_query: str | None = None
363
+ self._last_response: str | None = None
364
+
365
+ self._is_processing: bool = False
366
+ self._cancel_requested: bool = False
367
+ self._current_worker: Any = None
368
+
369
+ self._command_menu_visible: bool = False
370
+ self._stats_panel_visible: bool = True
371
+ self._custom_panel_visible: bool = config.custom_widget is not None
372
+
373
+ self._stats_snapshot: dict[str, Any] = dict(config.initial_stats)
374
+ self._session_start: datetime = datetime.now()
375
+ self._stats_timer: Any = None
376
+
377
+ self.theme = config.theme
378
+
379
+ # ── Compose / mount ─────────────────────────────────────────────
380
+
381
+ def compose(self) -> ComposeResult:
382
+ yield Header(show_clock=True)
383
+
384
+ commands = list(self._visible_commands())
385
+ yield _CommandMenuOverlay(commands, id="command-menu")
386
+
387
+ yield Static(f"🤖 {self._config.agent_name}", id="title-bar")
388
+ yield Static(f"💡 {self._config.agent_description}", id="agent-info")
389
+
390
+ with Horizontal(id="main-container"):
391
+ with Container(id="content-area"):
392
+ yield RichLog(
393
+ id="output-panel",
394
+ highlight=True,
395
+ markup=True,
396
+ wrap=True,
397
+ auto_scroll=True,
398
+ )
399
+
400
+ if self._config.custom_widget is not None:
401
+ with VerticalScroll(
402
+ id="custom-panel",
403
+ classes="visible" if self._custom_panel_visible else "",
404
+ ):
405
+ yield Static(
406
+ f"[bold cyan]{self._config.custom_widget_title}[/]",
407
+ id="custom-title",
408
+ )
409
+ yield self._config.custom_widget
410
+
411
+ with VerticalScroll(
412
+ id="stats-panel",
413
+ classes="visible" if self._stats_panel_visible else "",
414
+ ):
415
+ yield Static(
416
+ "[bold cyan]📊 Session Statistics[/]", id="stats-title"
417
+ )
418
+ yield Static("", id="stats-content")
419
+
420
+ with Horizontal(id="input-container"):
421
+ yield Input(
422
+ placeholder="Enter query or /command…",
423
+ id="query-input",
424
+ suggester=_SlashSuggester([c.name for c in commands]),
425
+ )
426
+ yield Button("Send", variant="primary", id="send-button")
427
+ yield Button("Clear", id="clear-button")
428
+
429
+ yield Static("Ready", id="status-bar")
430
+ yield Footer()
431
+
432
+ def on_mount(self) -> None:
433
+ self.query_one("#query-input", Input).focus()
434
+ self._show_welcome()
435
+ if self._stats_panel_visible:
436
+ self._refresh_stats_display()
437
+ self._stats_timer = self.set_interval(
438
+ 1.0, self._refresh_stats_display
439
+ )
440
+
441
+ # ── Welcome banner ─────────────────────────────────────────────
442
+
443
+ def _show_welcome(self) -> None:
444
+ out = self.query_one("#output-panel", RichLog)
445
+ lines = [
446
+ "[dim]• Type your query and press Enter[/]",
447
+ "[dim]• /cancel or Ctrl+X — cancel running agent[/]",
448
+ "[dim]• /save — save last response to a file[/]",
449
+ "[dim]• /clear — clear output[/]",
450
+ "[dim]• /reset — reset session[/]",
451
+ "[dim]• /quit — exit[/]",
452
+ "[dim]• /help — show this help[/]",
453
+ ]
454
+ if self._config.persona_provider is not None:
455
+ lines.append("[dim]• /persona — switch persona[/]")
456
+ if self._config.guideline_provider is not None:
457
+ lines.append("[dim]• /guideline — pick guideline[/]")
458
+ if self._config.custom_widget is not None:
459
+ lines.append("[dim]• /viz — toggle custom visualization[/]")
460
+
461
+ text = (
462
+ f"[bold]{self._config.agent_name}[/]\n\n"
463
+ f"{self._config.agent_description}\n\n"
464
+ "[dim]Commands:[/]\n" + "\n".join(lines)
465
+ )
466
+ out.write(Panel(text, border_style="dim", padding=(1, 2)))
467
+
468
+ # ── Slash command catalogue ────────────────────────────────────
469
+
470
+ def _visible_commands(self) -> list[SlashCommand]:
471
+ return [
472
+ c
473
+ for c in BUILTIN_COMMANDS
474
+ if not (c.requires_persona and self._config.persona_provider is None)
475
+ and not (
476
+ c.requires_guideline and self._config.guideline_provider is None
477
+ )
478
+ and not (
479
+ c.requires_custom_widget and self._config.custom_widget is None
480
+ )
481
+ ]
482
+
483
+ # ── Input event handlers ───────────────────────────────────────
484
+
485
+ async def on_input_submitted(self, _event: Input.Submitted) -> None:
486
+ await self._process_input()
487
+
488
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
489
+ if event.button.id == "send-button":
490
+ await self._process_input()
491
+ elif event.button.id == "clear-button":
492
+ self.action_clear()
493
+
494
+ async def on_input_changed(self, event: Input.Changed) -> None:
495
+ if event.input.id != "query-input":
496
+ return
497
+ value = event.value
498
+ if value.startswith("/"):
499
+ if len(value) == 1:
500
+ self._show_command_menu()
501
+ else:
502
+ self._filter_command_menu(value)
503
+ else:
504
+ self._hide_command_menu()
505
+
506
+ async def on_option_list_option_selected(
507
+ self, event: OptionList.OptionSelected
508
+ ) -> None:
509
+ if event.option_list.id != "command-menu-list":
510
+ return
511
+ command = event.option.id or ""
512
+ query_input = self.query_one("#query-input", Input)
513
+ query_input.value = command
514
+ self._hide_command_menu()
515
+ query_input.focus()
516
+ await self._handle_slash_command(command)
517
+
518
+ async def on_key(self, event: Any) -> None:
519
+ query_input = self.query_one("#query-input", Input)
520
+
521
+ if event.key == "escape" and self._command_menu_visible:
522
+ self._hide_command_menu()
523
+ query_input.focus()
524
+ event.prevent_default()
525
+ event.stop()
526
+ return
527
+
528
+ if self._command_menu_visible and event.key in ("down", "up"):
529
+ option_list = self.query_one("#command-menu-list", OptionList)
530
+ if not option_list.has_focus:
531
+ option_list.focus()
532
+ event.prevent_default()
533
+ event.stop()
534
+ return
535
+
536
+ if event.key == "tab" and query_input.has_focus:
537
+ if self._command_menu_visible:
538
+ option_list = self.query_one("#command-menu-list", OptionList)
539
+ if option_list.option_count > 0:
540
+ idx = option_list.highlighted or 0
541
+ option = option_list.get_option_at_index(idx)
542
+ if option.id:
543
+ query_input.value = option.id
544
+ query_input.cursor_position = len(option.id)
545
+ event.prevent_default()
546
+ event.stop()
547
+ return
548
+
549
+ current = query_input.value
550
+ if current.startswith("/") and query_input.suggester is not None:
551
+ suggestion = await query_input.suggester.get_suggestion(current)
552
+ if suggestion:
553
+ query_input.value = suggestion
554
+ query_input.cursor_position = len(suggestion)
555
+ event.prevent_default()
556
+ event.stop()
557
+
558
+ # ── Process input dispatcher ───────────────────────────────────
559
+
560
+ async def _process_input(self) -> None:
561
+ query_input = self.query_one("#query-input", Input)
562
+ value = query_input.value.strip()
563
+
564
+ if self._mode is not Mode.IDLE:
565
+ self._handle_modal_input(query_input, value)
566
+ return
567
+
568
+ query_input.placeholder = "Enter query or /command…"
569
+ if not value:
570
+ return
571
+ query_input.value = ""
572
+
573
+ if value.startswith("/"):
574
+ await self._handle_slash_command(value)
575
+ elif self._is_processing:
576
+ self._write_line(
577
+ "[yellow]⚠️ Agent is running. Use Ctrl+X to cancel.[/]"
578
+ )
579
+ else:
580
+ self._start_query(value)
581
+
582
+ def _handle_modal_input(self, query_input: Input, value: str) -> None:
583
+ """Dispatch *value* to the active modal flow and return to IDLE.
584
+
585
+ ``AgentTUI`` uses a tiny state machine — the input box does
586
+ triple duty as filename / persona-id / guideline-id picker
587
+ depending on ``self._mode``. Centralising the "clear box, run
588
+ handler, reset mode" loop here keeps :meth:`_process_input`
589
+ focused on the IDLE → query path.
590
+ """
591
+ query_input.value = ""
592
+
593
+ if self._mode is Mode.AWAITING_FILENAME:
594
+ filename = value or str(self._mode_payload or "")
595
+ if filename:
596
+ self._do_save(filename)
597
+ elif self._mode is Mode.AWAITING_PERSONA:
598
+ self._do_select_persona(value)
599
+ elif self._mode is Mode.AWAITING_GUIDELINE:
600
+ self._do_select_guideline(value)
601
+
602
+ self._mode = Mode.IDLE
603
+ self._mode_payload = None
604
+ query_input.placeholder = "Enter query or /command…"
605
+
606
+ # ── Slash command dispatch ─────────────────────────────────────
607
+
608
+ async def _handle_slash_command(self, command: str) -> None:
609
+ cmd = command.lower().strip()
610
+ handler = {
611
+ "/quit": self.action_quit,
612
+ "/cancel": self.action_cancel,
613
+ "/clear": self.action_clear,
614
+ "/reset": self.action_reset,
615
+ "/help": self._show_welcome,
616
+ "/save": self._begin_save,
617
+ "/persona": self._begin_persona,
618
+ "/guideline": self._begin_guideline,
619
+ "/stats": self._toggle_stats_panel,
620
+ "/viz": self._toggle_custom_panel,
621
+ }.get(cmd.split()[0] if cmd else cmd)
622
+
623
+ if handler is None:
624
+ self._write_line(f"[yellow]Unknown command: {command}[/]")
625
+ self._write_line("[dim]Type /help for available commands[/]")
626
+ return
627
+ handler()
628
+
629
+ # ── Save flow ──────────────────────────────────────────────────
630
+
631
+ def _begin_save(self) -> None:
632
+ if not self._last_response:
633
+ self._write_line("[yellow]No response to save yet[/]")
634
+ return
635
+
636
+ suggested = self._config.save_handler.suggest_filename(
637
+ self._last_query, self._last_response
638
+ )
639
+ self._write_panel(
640
+ f"[cyan]Save response to:[/]\n\n[bold]{suggested}[/]\n\n"
641
+ "[dim]Press Enter to accept, or type a different filename[/]",
642
+ border_style="cyan",
643
+ )
644
+ query_input = self.query_one("#query-input", Input)
645
+ query_input.placeholder = f"Filename (or Enter for: {suggested})"
646
+ query_input.value = suggested
647
+ self._mode = Mode.AWAITING_FILENAME
648
+ self._mode_payload = suggested
649
+
650
+ def _do_save(self, filename: str) -> None:
651
+ if self._last_response is None:
652
+ return
653
+ try:
654
+ path = self._config.save_handler.save(self._last_response, filename)
655
+ except Exception as exc:
656
+ self._write_line(f"[red]Error saving file: {exc}[/]")
657
+ self._update_status("Save failed")
658
+ return
659
+ self._write_line(
660
+ f"[green]✓ Saved successfully![/]\n[cyan]Location:[/] [bold]{path}[/]"
661
+ )
662
+ self._update_status(f"Saved to {path.name}")
663
+
664
+ # ── Persona flow ───────────────────────────────────────────────
665
+
666
+ def _begin_persona(self) -> None:
667
+ provider = self._config.persona_provider
668
+ if provider is None:
669
+ self._write_line("[yellow]Persona switching not available[/]")
670
+ return
671
+
672
+ ids = provider.list_ids()
673
+ if not ids:
674
+ self._write_line("[yellow]No personas found[/]")
675
+ return
676
+ current = provider.current_id()
677
+ lines = "\n".join(
678
+ f" {'→' if pid == current else ' '} {i + 1}. {pid}"
679
+ for i, pid in enumerate(ids)
680
+ )
681
+ self._write_panel(
682
+ f"[cyan]Available Personas:[/]\n\n{lines}\n\n"
683
+ "[dim]Type the number or name (Enter to cancel)[/]",
684
+ border_style="cyan",
685
+ )
686
+ query_input = self.query_one("#query-input", Input)
687
+ query_input.placeholder = "Persona number or name…"
688
+ query_input.value = ""
689
+ self._mode = Mode.AWAITING_PERSONA
690
+ self._mode_payload = ids
691
+
692
+ def _do_select_persona(self, selection: str) -> None:
693
+ provider = self._config.persona_provider
694
+ ids: list[str] = list(self._mode_payload or [])
695
+ if not selection or provider is None:
696
+ self._write_line("[dim]Persona switch cancelled[/]")
697
+ return
698
+ chosen = _resolve_selection(selection, ids)
699
+ if chosen is None:
700
+ self._write_line(f"[yellow]No persona matches: {selection}[/]")
701
+ return
702
+ try:
703
+ provider.select(chosen)
704
+ except Exception as exc:
705
+ self._write_line(f"[red]Failed to switch persona: {exc}[/]")
706
+ return
707
+ self._write_line(f"[green]✓ Switched to persona: {chosen}[/]")
708
+ self._update_status(f"Persona: {chosen}")
709
+
710
+ # ── Guideline flow ─────────────────────────────────────────────
711
+
712
+ def _begin_guideline(self) -> None:
713
+ provider = self._config.guideline_provider
714
+ if provider is None:
715
+ self._write_line("[yellow]Guideline selection not available[/]")
716
+ return
717
+
718
+ ids = provider.list_ids()
719
+ if not ids:
720
+ self._write_line("[yellow]No guidelines found[/]")
721
+ return
722
+ current = provider.current_id()
723
+ zero = (
724
+ " → 0. [None — clear current guideline]"
725
+ if current is None
726
+ else " 0. [None — clear current guideline]"
727
+ )
728
+ rest = "\n".join(
729
+ f" {'→' if gid == current else ' '} {i + 1}. {gid}"
730
+ for i, gid in enumerate(ids)
731
+ )
732
+ self._write_panel(
733
+ f"[cyan]Available Guidelines:[/]\n\n{zero}\n{rest}\n\n"
734
+ "[dim]Type number/name; 0/none/clear removes (Enter to cancel)[/]",
735
+ border_style="cyan",
736
+ )
737
+ query_input = self.query_one("#query-input", Input)
738
+ query_input.placeholder = "Guideline number, name, or 0 to clear…"
739
+ query_input.value = ""
740
+ self._mode = Mode.AWAITING_GUIDELINE
741
+ self._mode_payload = ids
742
+
743
+ def _do_select_guideline(self, selection: str) -> None:
744
+ provider = self._config.guideline_provider
745
+ ids: list[str] = list(self._mode_payload or [])
746
+ if not selection or provider is None:
747
+ self._write_line("[dim]Guideline selection cancelled[/]")
748
+ return
749
+ if selection.lower() in ("0", "none", "clear"):
750
+ try:
751
+ provider.clear()
752
+ except Exception as exc:
753
+ self._write_line(f"[red]Failed to clear guideline: {exc}[/]")
754
+ return
755
+ self._write_line("[green]✓ Guideline cleared[/]")
756
+ self._update_status("Guideline: None")
757
+ return
758
+ chosen = _resolve_selection(selection, ids)
759
+ if chosen is None:
760
+ self._write_line(f"[yellow]No guideline matches: {selection}[/]")
761
+ return
762
+ try:
763
+ provider.select(chosen)
764
+ except Exception as exc:
765
+ self._write_line(f"[red]Failed to switch guideline: {exc}[/]")
766
+ return
767
+ self._write_line(f"[green]✓ Switched to guideline: {chosen}[/]")
768
+ self._update_status(f"Guideline: {chosen}")
769
+
770
+ # ── Stats & custom panels ──────────────────────────────────────
771
+
772
+ def _toggle_stats_panel(self) -> None:
773
+ panel = self.query_one("#stats-panel")
774
+ self._stats_panel_visible = not self._stats_panel_visible
775
+ if self._stats_panel_visible:
776
+ panel.add_class("visible")
777
+ self._refresh_stats_display()
778
+ if self._stats_timer is None:
779
+ self._stats_timer = self.set_interval(
780
+ 1.0, self._refresh_stats_display
781
+ )
782
+ else:
783
+ panel.remove_class("visible")
784
+ if self._stats_timer is not None:
785
+ self._stats_timer.stop()
786
+ self._stats_timer = None
787
+
788
+ def _toggle_custom_panel(self) -> None:
789
+ if self._config.custom_widget is None:
790
+ self._write_line(
791
+ "[yellow]No custom visualization available[/]"
792
+ )
793
+ return
794
+ panel = self.query_one("#custom-panel")
795
+ self._custom_panel_visible = not self._custom_panel_visible
796
+ if self._custom_panel_visible:
797
+ panel.add_class("visible")
798
+ else:
799
+ panel.remove_class("visible")
800
+
801
+ def _refresh_stats_display(self) -> None:
802
+ content = self.query_one("#stats-content", Static)
803
+ elapsed = datetime.now() - self._session_start
804
+ hours, rem = divmod(int(elapsed.total_seconds()), 3600)
805
+ minutes, seconds = divmod(rem, 60)
806
+ if hours:
807
+ dur = f"{hours}h {minutes}m {seconds}s"
808
+ elif minutes:
809
+ dur = f"{minutes}m {seconds}s"
810
+ else:
811
+ dur = f"{seconds}s"
812
+
813
+ lines = [
814
+ "\n[dim]Session Duration[/]",
815
+ f"[bold cyan]{dur}[/]",
816
+ "",
817
+ ]
818
+ for key, value in self._stats_snapshot.items():
819
+ lines.append(f"[dim]{key}[/]")
820
+ lines.append(f"[bold green]{value}[/]")
821
+ lines.append("")
822
+ content.update("\n".join(lines))
823
+
824
+ # ── Worker: run agent ──────────────────────────────────────────
825
+
826
+ def _start_query(self, query: str) -> None:
827
+ self._current_worker = self._run_agent_worker(query)
828
+
829
+ @work(exclusive=True, thread=True) # type: ignore[untyped-decorator]
830
+ async def _run_agent_worker(self, query: str) -> None:
831
+ self._is_processing = True
832
+ self._cancel_requested = False
833
+ self._last_query = query
834
+ self._query_history.append(query)
835
+ self._update_status("[bright_cyan]Processing…[/]")
836
+ self._post_query_panel(query)
837
+ self._start_thinking()
838
+
839
+ controls = TuiControls(self)
840
+ try:
841
+ if self._cancel_requested:
842
+ raise asyncio.CancelledError("Cancelled by user")
843
+ result = await self._invoke_runner(query, controls)
844
+ self._last_response = result.text
845
+ if result.stats is not None:
846
+ self._stats_snapshot = dict(result.stats)
847
+ self._post_response_panel(result.text)
848
+ self._update_status("[bright_green]Ready[/]")
849
+ except asyncio.CancelledError:
850
+ self.call_from_thread(self._show_cancelled_panel)
851
+ self.call_from_thread(
852
+ lambda: self._update_status("[red]Cancelled[/]")
853
+ )
854
+ except Exception as exc:
855
+ err = str(exc)
856
+ self.call_from_thread(self._show_error_panel, err)
857
+ self.call_from_thread(
858
+ lambda: self._update_status("[bright_red]Error[/]")
859
+ )
860
+ finally:
861
+ self.call_from_thread(self._on_query_finished)
862
+
863
+ async def _invoke_runner(
864
+ self, query: str, controls: TuiControls
865
+ ) -> RunResult:
866
+ """Invoke the runner protocol method or a plain callable."""
867
+ runner: Any = self._config.runner
868
+ if hasattr(runner, "run"):
869
+ result = await runner.run(query, controls)
870
+ else:
871
+ # Allow plain awaitable callables for ergonomics.
872
+ sig = inspect.signature(runner)
873
+ kwargs: dict[str, Any] = {}
874
+ if "controls" in sig.parameters:
875
+ kwargs["controls"] = controls
876
+ result = await runner(query, **kwargs)
877
+
878
+ if isinstance(result, RunResult):
879
+ return result
880
+ if isinstance(result, str):
881
+ return RunResult(text=result)
882
+ text = getattr(result, "final_output", None) or str(result)
883
+ stats = getattr(result, "stats", None)
884
+ if stats is not None and not isinstance(stats, Mapping):
885
+ stats = {
886
+ attr: getattr(stats, attr)
887
+ for attr in dir(stats)
888
+ if not attr.startswith("_")
889
+ and not callable(getattr(stats, attr))
890
+ }
891
+ return RunResult(text=text, stats=stats)
892
+
893
+ # ── Posting from worker thread ─────────────────────────────────
894
+
895
+ def _post_query_panel(self, query: str) -> None:
896
+ def render() -> None:
897
+ self._write_panel(
898
+ f"[bold bright_white]{query}[/]",
899
+ title=(
900
+ f"[bright_cyan]You[/] · "
901
+ f"{datetime.now().strftime('%H:%M')}"
902
+ ),
903
+ border_style="bright_cyan",
904
+ )
905
+
906
+ self.call_from_thread(render)
907
+
908
+ def _post_response_panel(self, response: str) -> None:
909
+ def render() -> None:
910
+ out = self.query_one("#output-panel", RichLog)
911
+ out.write(
912
+ Panel(
913
+ Markdown(response),
914
+ title=(
915
+ f"[bright_green]{self._config.agent_name}[/] · "
916
+ f"{datetime.now().strftime('%H:%M')}"
917
+ ),
918
+ border_style="green",
919
+ padding=(1, 2),
920
+ )
921
+ )
922
+ out.write("")
923
+
924
+ self.call_from_thread(render)
925
+
926
+ def _show_cancelled_panel(self) -> None:
927
+ self._write_panel(
928
+ "[bold red]Agent execution cancelled by user[/]\n\n"
929
+ "[dim]The current operation has been interrupted.[/]",
930
+ border_style="red",
931
+ )
932
+
933
+ def _show_error_panel(self, error: str) -> None:
934
+ self._write_panel(
935
+ f"[bold bright_red]Error: {error}[/]",
936
+ border_style="bright_red",
937
+ )
938
+
939
+ def _on_query_finished(self) -> None:
940
+ self._stop_thinking()
941
+ self._is_processing = False
942
+ self._current_worker = None
943
+ self._cancel_requested = False
944
+ with contextlib.suppress(Exception):
945
+ self.query_one("#query-input", Input).focus()
946
+
947
+ # ── Worker-side callbacks for TuiControls ──────────────────────
948
+
949
+ def _post_progress(self, message: str) -> None:
950
+ def render() -> None:
951
+ out = self.query_one("#output-panel", RichLog)
952
+ for line in message.split("\n"):
953
+ if line.strip():
954
+ out.write(f"[bright_yellow]{line}[/]")
955
+ out.refresh()
956
+
957
+ with contextlib.suppress(Exception):
958
+ self.call_from_thread(render)
959
+
960
+ def _post_stats(self, stats: Mapping[str, Any]) -> None:
961
+ def render() -> None:
962
+ self._stats_snapshot = dict(stats)
963
+ self._refresh_stats_display()
964
+
965
+ with contextlib.suppress(Exception):
966
+ self.call_from_thread(render)
967
+
968
+ def _post_custom_widget(self, payload: Any) -> None:
969
+ widget = self._config.custom_widget
970
+ if widget is None or not hasattr(widget, "handle_payload"):
971
+ return
972
+
973
+ def render() -> None:
974
+ widget.handle_payload(payload)
975
+
976
+ with contextlib.suppress(Exception):
977
+ self.call_from_thread(render)
978
+
979
+ # ── Thinking spinner ───────────────────────────────────────────
980
+
981
+ _SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
982
+
983
+ def _start_thinking(self) -> None:
984
+ def render() -> None:
985
+ out = self.query_one("#output-panel", RichLog)
986
+ out.write("[bright_yellow]Processing query…[/]")
987
+ self._spinner_frame = 0
988
+ self._spinner_timer = self.set_interval(0.1, self._tick_spinner)
989
+
990
+ self.call_from_thread(render)
991
+
992
+ def _stop_thinking(self) -> None:
993
+ timer = getattr(self, "_spinner_timer", None)
994
+ if timer is not None:
995
+ timer.stop()
996
+ self._spinner_timer = None
997
+
998
+ def _tick_spinner(self) -> None:
999
+ if not self._is_processing:
1000
+ return
1001
+ frame = self._SPINNER_FRAMES[
1002
+ self._spinner_frame % len(self._SPINNER_FRAMES)
1003
+ ]
1004
+ try:
1005
+ status_bar = self.query_one("#status-bar", Static)
1006
+ status_bar.update(f"[dim]{frame} Processing…[/]")
1007
+ except Exception:
1008
+ return
1009
+ self._spinner_frame += 1
1010
+
1011
+ # ── Tiny helpers ───────────────────────────────────────────────
1012
+
1013
+ def _show_command_menu(self) -> None:
1014
+ try:
1015
+ overlay = self.query_one("#command-menu", _CommandMenuOverlay)
1016
+ option_list = self.query_one("#command-menu-list", OptionList)
1017
+ option_list.clear_options()
1018
+ for cmd in overlay.commands:
1019
+ option_list.add_option(
1020
+ Option(
1021
+ f"{cmd.name} [dim]{cmd.description}[/]",
1022
+ id=cmd.name,
1023
+ )
1024
+ )
1025
+ overlay.add_class("visible")
1026
+ self._command_menu_visible = True
1027
+ except Exception:
1028
+ pass
1029
+
1030
+ def _hide_command_menu(self) -> None:
1031
+ try:
1032
+ self.query_one("#command-menu", _CommandMenuOverlay).remove_class(
1033
+ "visible"
1034
+ )
1035
+ self._command_menu_visible = False
1036
+ except Exception:
1037
+ pass
1038
+
1039
+ def _filter_command_menu(self, value: str) -> None:
1040
+ try:
1041
+ overlay = self.query_one("#command-menu", _CommandMenuOverlay)
1042
+ option_list = self.query_one("#command-menu-list", OptionList)
1043
+ matches = [
1044
+ c for c in overlay.commands if c.name.startswith(value.lower())
1045
+ ]
1046
+ if not matches:
1047
+ self._hide_command_menu()
1048
+ return
1049
+ option_list.clear_options()
1050
+ for cmd in matches:
1051
+ option_list.add_option(
1052
+ Option(
1053
+ f"{cmd.name} [dim]{cmd.description}[/]",
1054
+ id=cmd.name,
1055
+ )
1056
+ )
1057
+ overlay.add_class("visible")
1058
+ self._command_menu_visible = True
1059
+ except Exception:
1060
+ pass
1061
+
1062
+ def _write_line(self, text: str) -> None:
1063
+ with contextlib.suppress(Exception):
1064
+ self.query_one("#output-panel", RichLog).write(text)
1065
+
1066
+ def _write_panel(
1067
+ self,
1068
+ text: str,
1069
+ *,
1070
+ title: str | None = None,
1071
+ border_style: str = "dim",
1072
+ ) -> None:
1073
+ with contextlib.suppress(Exception):
1074
+ self.query_one("#output-panel", RichLog).write(
1075
+ Panel(
1076
+ text,
1077
+ title=title,
1078
+ border_style=border_style,
1079
+ padding=(1, 2),
1080
+ )
1081
+ )
1082
+
1083
+ def _update_status(self, message: str) -> None:
1084
+ with contextlib.suppress(Exception):
1085
+ self.query_one("#status-bar", Static).update(message)
1086
+
1087
+ # ── Actions (bindings) ─────────────────────────────────────────
1088
+
1089
+ def action_clear(self) -> None:
1090
+ try:
1091
+ out = self.query_one("#output-panel", RichLog)
1092
+ out.clear()
1093
+ self._show_welcome()
1094
+ self._update_status("Cleared")
1095
+ except Exception:
1096
+ pass
1097
+
1098
+ def action_reset(self) -> None:
1099
+ self._query_history.clear()
1100
+ self._last_response = None
1101
+ self._last_query = None
1102
+ self.action_clear()
1103
+ self._update_status("Reset")
1104
+
1105
+ def action_save(self) -> None:
1106
+ self._begin_save()
1107
+
1108
+ def action_cancel(self) -> None:
1109
+ if not self._is_processing:
1110
+ self._write_line("[dim]No agent currently running[/]")
1111
+ return
1112
+ self._write_line("[bold red]🛑 Cancellation requested…[/]")
1113
+ self._write_line(
1114
+ "[dim]Note: the agent will stop after the current operation.[/]"
1115
+ )
1116
+ self._cancel_requested = True
1117
+ if self._current_worker is not None:
1118
+ with contextlib.suppress(Exception):
1119
+ self._current_worker.cancel()
1120
+ self._update_status("[red]Cancelling…[/]")
1121
+
1122
+ def action_quit(self) -> None:
1123
+ if self._is_processing and self._current_worker is not None:
1124
+ with contextlib.suppress(Exception):
1125
+ self._current_worker.cancel()
1126
+ self.exit()
1127
+
1128
+
1129
+ # ── Selection helpers ───────────────────────────────────────────────
1130
+
1131
+
1132
+ def _resolve_selection(selection: str, ids: list[str]) -> str | None:
1133
+ """Resolve a user selection by index (1-based), exact name, or substring."""
1134
+ try:
1135
+ idx = int(selection) - 1
1136
+ except ValueError:
1137
+ pass
1138
+ else:
1139
+ return ids[idx] if 0 <= idx < len(ids) else None
1140
+
1141
+ lower = selection.lower()
1142
+ exact = [i for i in ids if i.lower() == lower]
1143
+ if exact:
1144
+ return exact[0]
1145
+ partial = [i for i in ids if lower in i.lower()]
1146
+ if len(partial) == 1:
1147
+ return partial[0]
1148
+ return None
1149
+
1150
+
1151
+ # ── Public entry point ──────────────────────────────────────────────
1152
+
1153
+
1154
+ def run_agent_tui(config: TuiConfig) -> None:
1155
+ """Run an :class:`AgentTUI` to completion."""
1156
+ AgentTUI(config).run()
1157
+
1158
+
1159
+ __all__ = [
1160
+ "AgentRunner",
1161
+ "AgentTUI",
1162
+ "BUILTIN_COMMANDS",
1163
+ "FileSaveHandler",
1164
+ "GuidelineProvider",
1165
+ "Mode",
1166
+ "PersonaProvider",
1167
+ "RunResult",
1168
+ "SaveHandler",
1169
+ "SlashCommand",
1170
+ "TuiConfig",
1171
+ "TuiControls",
1172
+ "run_agent_tui",
1173
+ ]
1174
+
1175
+
1176
+ # Allow type-checkers to see Screen is referenced (used in module annotation
1177
+ # scope by Textual when running) without inflating runtime usage.
1178
+ _ = Screen # noqa: F841