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.
- ellements/__init__.py +57 -0
- ellements/agents/__init__.py +45 -0
- ellements/agents/backend.py +100 -0
- ellements/agents/builder.py +303 -0
- ellements/agents/claude_backend.py +200 -0
- ellements/agents/controller.py +237 -0
- ellements/agents/openai_backend.py +187 -0
- ellements/agents/py.typed +0 -0
- ellements/agents/runner.py +358 -0
- ellements/agents/tools.py +30 -0
- ellements/benchmarking/__init__.py +52 -0
- ellements/benchmarking/harness.py +342 -0
- ellements/benchmarking/py.typed +0 -0
- ellements/benchmarking/results.py +173 -0
- ellements/cli/__init__.py +80 -0
- ellements/cli/adapters.py +73 -0
- ellements/cli/agent_tui.py +1178 -0
- ellements/cli/components.py +411 -0
- ellements/cli/printer.py +229 -0
- ellements/cli/py.typed +0 -0
- ellements/core/__init__.py +112 -0
- ellements/core/async_utils.py +42 -0
- ellements/core/budgeting/__init__.py +43 -0
- ellements/core/budgeting/client.py +276 -0
- ellements/core/budgeting/protocol.py +51 -0
- ellements/core/budgeting/trackers.py +177 -0
- ellements/core/caching/__init__.py +51 -0
- ellements/core/caching/cache.py +73 -0
- ellements/core/caching/client.py +300 -0
- ellements/core/caching/disk.py +128 -0
- ellements/core/caching/keys.py +97 -0
- ellements/core/caching/memory.py +104 -0
- ellements/core/chunking.py +262 -0
- ellements/core/config.py +180 -0
- ellements/core/exceptions.py +145 -0
- ellements/core/llm/__init__.py +46 -0
- ellements/core/llm/client.py +1124 -0
- ellements/core/llm/images.py +226 -0
- ellements/core/llm/messages.py +202 -0
- ellements/core/llm/model_params.py +66 -0
- ellements/core/llm/protocol.py +146 -0
- ellements/core/llm/requests.py +100 -0
- ellements/core/llm/structured.py +91 -0
- ellements/core/llm/wrapper.py +79 -0
- ellements/core/observability/__init__.py +39 -0
- ellements/core/observability/events.py +169 -0
- ellements/core/observability/jsonl_logger.py +244 -0
- ellements/core/observability/markdown_formatter.py +197 -0
- ellements/core/observability/observer.py +56 -0
- ellements/core/prompting/__init__.py +14 -0
- ellements/core/prompting/context.py +185 -0
- ellements/core/prompting/guideline.py +133 -0
- ellements/core/prompting/persona.py +267 -0
- ellements/core/prompting/sources.py +92 -0
- ellements/core/py.typed +0 -0
- ellements/core/rate_limit/__init__.py +44 -0
- ellements/core/rate_limit/bucket.py +85 -0
- ellements/core/rate_limit/client.py +216 -0
- ellements/core/rate_limit/protocol.py +27 -0
- ellements/core/templating.py +126 -0
- ellements/core/tools/__init__.py +51 -0
- ellements/core/tools/dialects.py +136 -0
- ellements/core/tools/executor.py +48 -0
- ellements/core/tools/protocol.py +36 -0
- ellements/core/tools/records.py +28 -0
- ellements/core/tools/registry.py +205 -0
- ellements/core/tools/schemas.py +80 -0
- ellements/core/tools/simple.py +119 -0
- ellements/core/tools/spec.py +33 -0
- ellements/domain_specific/__init__.py +7 -0
- ellements/domain_specific/finance/__init__.py +188 -0
- ellements/domain_specific/finance/calculations.py +837 -0
- ellements/domain_specific/finance/charts.py +426 -0
- ellements/domain_specific/finance/portfolio.py +129 -0
- ellements/domain_specific/finance/quant_analysis.py +279 -0
- ellements/domain_specific/finance/risk.py +362 -0
- ellements/domain_specific/finance/technical_indicators.py +241 -0
- ellements/domain_specific/finance/tools.py +483 -0
- ellements/domain_specific/finance/valuation.py +312 -0
- ellements/domain_specific/finance/yahoo_finance.py +1523 -0
- ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
- ellements/domain_specific/py.typed +0 -0
- ellements/execution/__init__.py +56 -0
- ellements/execution/callbacks.py +149 -0
- ellements/execution/catalog.py +70 -0
- ellements/execution/collaborative.py +191 -0
- ellements/execution/config.py +135 -0
- ellements/execution/py.typed +0 -0
- ellements/execution/reflection.py +156 -0
- ellements/execution/self_consistency.py +189 -0
- ellements/execution/single_call.py +48 -0
- ellements/execution/strategies.py +237 -0
- ellements/execution/tree_of_thought.py +541 -0
- ellements/fslm/__init__.py +108 -0
- ellements/fslm/builtins.py +103 -0
- ellements/fslm/cli.py +199 -0
- ellements/fslm/context.py +50 -0
- ellements/fslm/definition.py +163 -0
- ellements/fslm/det.py +173 -0
- ellements/fslm/dsl.py +458 -0
- ellements/fslm/errors.py +38 -0
- ellements/fslm/evaluators.py +141 -0
- ellements/fslm/kernel.py +603 -0
- ellements/fslm/loading.py +123 -0
- ellements/fslm/models.py +623 -0
- ellements/fslm/nl.py +87 -0
- ellements/fslm/observers.py +107 -0
- ellements/fslm/persistence.py +72 -0
- ellements/fslm/py.typed +0 -0
- ellements/fslm/rendering.py +551 -0
- ellements/fslm/visualization.py +25 -0
- ellements/reporting/__init__.py +12 -0
- ellements/reporting/charts.py +364 -0
- ellements/reporting/html_generation.py +132 -0
- ellements/reporting/py.typed +0 -0
- ellements/reporting/visualization.py +73 -0
- ellements/standard_tools/__init__.py +22 -0
- ellements/standard_tools/py.typed +0 -0
- ellements/standard_tools/terminal.py +205 -0
- ellements/standard_tools/web/__init__.py +37 -0
- ellements/standard_tools/web/browser_viewer.py +214 -0
- ellements/standard_tools/web/crawler.py +454 -0
- ellements/standard_tools/web/search.py +399 -0
- ellements/standard_tools/web/youtube.py +1297 -0
- ellements-0.2.0.dist-info/METADATA +368 -0
- ellements-0.2.0.dist-info/RECORD +130 -0
- ellements-0.2.0.dist-info/WHEEL +5 -0
- ellements-0.2.0.dist-info/entry_points.txt +2 -0
- ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
- 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
|