open-data-sci 0.1.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.
- open_data_sci-0.1.0.dist-info/METADATA +629 -0
- open_data_sci-0.1.0.dist-info/RECORD +85 -0
- open_data_sci-0.1.0.dist-info/WHEEL +4 -0
- open_data_sci-0.1.0.dist-info/entry_points.txt +2 -0
- open_data_sci-0.1.0.dist-info/licenses/LICENSE +201 -0
- opendatasci/__init__.py +47 -0
- opendatasci/_tui/__init__.py +1 -0
- opendatasci/_tui/adapter.py +102 -0
- opendatasci/_tui/app.py +429 -0
- opendatasci/_tui/commands.py +95 -0
- opendatasci/_tui/completion.py +139 -0
- opendatasci/_tui/controller.py +644 -0
- opendatasci/_tui/file_refs.py +153 -0
- opendatasci/_tui/models.py +4 -0
- opendatasci/_tui/presenter.py +259 -0
- opendatasci/_tui/service.py +78 -0
- opendatasci/_tui/session.py +53 -0
- opendatasci/_tui/styles.tcss +248 -0
- opendatasci/_tui/styles_visible.tcss +245 -0
- opendatasci/_tui/theme.py +113 -0
- opendatasci/_tui/tools_display.py +86 -0
- opendatasci/_tui/widgets.py +1001 -0
- opendatasci/_utils/__init__.py +0 -0
- opendatasci/_utils/async_utils.py +11 -0
- opendatasci/_utils/data_formats.py +135 -0
- opendatasci/_utils/hash_utils.py +52 -0
- opendatasci/_utils/langchain_utils.py +155 -0
- opendatasci/_utils/streaming_utils.py +23 -0
- opendatasci/agents/__init__.py +12 -0
- opendatasci/agents/agents.py +515 -0
- opendatasci/agents/agents_factory.py +71 -0
- opendatasci/agents/chat_memory.py +397 -0
- opendatasci/agents/graphs.py +84 -0
- opendatasci/agents/nodes.py +74 -0
- opendatasci/agents/states.py +36 -0
- opendatasci/agents/turn_memory.py +124 -0
- opendatasci/configs.py +275 -0
- opendatasci/context/__init__.py +7 -0
- opendatasci/context/base.py +56 -0
- opendatasci/context/local.py +236 -0
- opendatasci/models/__init__.py +7 -0
- opendatasci/models/anthropic.py +40 -0
- opendatasci/models/aws.py +86 -0
- opendatasci/models/factory.py +179 -0
- opendatasci/models/google.py +79 -0
- opendatasci/models/local.py +79 -0
- opendatasci/models/microsoft.py +62 -0
- opendatasci/models/openai.py +49 -0
- opendatasci/models/providers.py +12 -0
- opendatasci/prompts/__init__.py +5 -0
- opendatasci/prompts/builders.py +85 -0
- opendatasci/prompts/caching.py +42 -0
- opendatasci/prompts/message_templates.py +7 -0
- opendatasci/prompts/prompt_templates.py +227 -0
- opendatasci/resources/skills/competitive_data_science.md +241 -0
- opendatasci/resources/skills/data_science.md +55 -0
- opendatasci/resources/skills/data_science_education.md +42 -0
- opendatasci/resources/skills/deep_learning.md +205 -0
- opendatasci/resources/skills/machine_learning.md +68 -0
- opendatasci/resources/skills/quantitative_analysis.md +45 -0
- opendatasci/sandbox/__init__.py +14 -0
- opendatasci/sandbox/_runner.py +114 -0
- opendatasci/sandbox/base.py +170 -0
- opendatasci/sandbox/srt.py +490 -0
- opendatasci/skills/__init__.py +9 -0
- opendatasci/skills/base.py +28 -0
- opendatasci/skills/local.py +131 -0
- opendatasci/streaming/__init__.py +37 -0
- opendatasci/streaming/events.py +159 -0
- opendatasci/streaming/processors.py +387 -0
- opendatasci/tools/__init__.py +58 -0
- opendatasci/tools/coding.py +261 -0
- opendatasci/tools/critic.py +136 -0
- opendatasci/tools/dataset_info.py +391 -0
- opendatasci/tools/factory.py +172 -0
- opendatasci/tools/mcp.py +179 -0
- opendatasci/tools/planning.py +88 -0
- opendatasci/tools/skills.py +90 -0
- opendatasci/tools/user_interaction.py +54 -0
- opendatasci/tools/web.py +236 -0
- opendatasci/tools/workers.py +237 -0
- opendatasci/tools/workspace.py +55 -0
- opendatasci/workspace/__init__.py +9 -0
- opendatasci/workspace/base.py +20 -0
- opendatasci/workspace/local.py +25 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
"""CLIController — application state and event routing for the OpenDataSci TUI.
|
|
2
|
+
|
|
3
|
+
Concerns deliberately kept here:
|
|
4
|
+
- Lifecycle (boot, close)
|
|
5
|
+
- Input routing (on_input_changed, on_submit)
|
|
6
|
+
- Slash-command dispatch
|
|
7
|
+
- Choice-prompt state machine
|
|
8
|
+
- Action methods (reset, clear, compact, show_models, show_help, stop, ls_workspace)
|
|
9
|
+
|
|
10
|
+
Everything else has been extracted into focused sibling modules:
|
|
11
|
+
- adapter.py — UIAdapter + handle ABCs
|
|
12
|
+
- commands.py — SLASH_COMMANDS registry + display formatters
|
|
13
|
+
- completion.py — CompletionState (tab-completion logic)
|
|
14
|
+
- file_refs.py — @file-ref parsing helpers
|
|
15
|
+
- presenter.py — _TurnPresenter (streaming event dispatch)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import difflib
|
|
19
|
+
import logging
|
|
20
|
+
import string
|
|
21
|
+
from contextlib import AsyncExitStack
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from rich.markup import escape as escape_markup
|
|
25
|
+
|
|
26
|
+
from opendatasci._tui.service import OpenDataSciTuiService
|
|
27
|
+
from opendatasci._tui.session import CLISessionInfo
|
|
28
|
+
from opendatasci.agents.agents_factory import create_agent
|
|
29
|
+
from opendatasci.configs import OpenDataSciConfig
|
|
30
|
+
from opendatasci.streaming import BaseAgentStreamEvent
|
|
31
|
+
from opendatasci.streaming.events import (
|
|
32
|
+
ErrorEvent,
|
|
33
|
+
InputRequiredEvent,
|
|
34
|
+
ReasoningEvent,
|
|
35
|
+
ResponseEvent,
|
|
36
|
+
SubagentEvent,
|
|
37
|
+
TokenEvent,
|
|
38
|
+
ToolCallEvent,
|
|
39
|
+
ToolCommunicationEvent,
|
|
40
|
+
ToolResultEvent,
|
|
41
|
+
UsageEvent,
|
|
42
|
+
WorkerDoneEvent,
|
|
43
|
+
)
|
|
44
|
+
from opendatasci.tools.mcp import load_mcp_servers
|
|
45
|
+
|
|
46
|
+
from . import theme as _theme
|
|
47
|
+
from .adapter import EphemeralHandle, MessageHandle, TurnStatusHandle, UIAdapter
|
|
48
|
+
from .commands import (
|
|
49
|
+
SLASH_COMMAND_DESCRIPTIONS,
|
|
50
|
+
SLASH_COMMANDS,
|
|
51
|
+
format_help_message,
|
|
52
|
+
format_models_message,
|
|
53
|
+
format_themes_message,
|
|
54
|
+
)
|
|
55
|
+
from .completion import CompletionState
|
|
56
|
+
from .file_refs import (
|
|
57
|
+
PasteAttachment,
|
|
58
|
+
_build_agent_query,
|
|
59
|
+
_build_user_display,
|
|
60
|
+
_discover_files,
|
|
61
|
+
_FileRef,
|
|
62
|
+
_find_at_fragment,
|
|
63
|
+
_find_slash_fragment,
|
|
64
|
+
_parse_file_refs,
|
|
65
|
+
_split_existing_file_refs,
|
|
66
|
+
)
|
|
67
|
+
from .presenter import _TurnPresenter
|
|
68
|
+
from .theme import active as theme
|
|
69
|
+
|
|
70
|
+
logger = logging.getLogger(__name__)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Re-export handle ABCs so existing imports from this module keep working.
|
|
74
|
+
__all__ = [
|
|
75
|
+
"CLIController",
|
|
76
|
+
"EphemeralHandle",
|
|
77
|
+
"MessageHandle",
|
|
78
|
+
"SLASH_COMMAND_DESCRIPTIONS",
|
|
79
|
+
"SLASH_COMMANDS",
|
|
80
|
+
"TurnStatusHandle",
|
|
81
|
+
"UIAdapter",
|
|
82
|
+
"_FileRef",
|
|
83
|
+
"_build_agent_query",
|
|
84
|
+
"_build_user_display",
|
|
85
|
+
"_discover_files",
|
|
86
|
+
"_find_at_fragment",
|
|
87
|
+
"_find_slash_fragment",
|
|
88
|
+
"_parse_file_refs",
|
|
89
|
+
"_split_existing_file_refs",
|
|
90
|
+
"PasteAttachment",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class CLIController:
|
|
95
|
+
"""Owns application state and all non-Textual business logic for the TUI."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
ui: UIAdapter,
|
|
100
|
+
workspace_path: str,
|
|
101
|
+
datasci_config: OpenDataSciConfig,
|
|
102
|
+
session_id: str,
|
|
103
|
+
completion: CompletionState | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
self._ui = ui
|
|
106
|
+
self._workspace_path = workspace_path
|
|
107
|
+
self._base_config = datasci_config
|
|
108
|
+
self._session_id = session_id
|
|
109
|
+
self._service: OpenDataSciTuiService | None = None
|
|
110
|
+
self._exit_stack: AsyncExitStack = AsyncExitStack()
|
|
111
|
+
self._awaiting_choice: bool = False
|
|
112
|
+
self._pending_choices: list[str] = []
|
|
113
|
+
self._other_choice_label: str | None = None
|
|
114
|
+
self._awaiting_custom_choice_input: bool = False
|
|
115
|
+
self._active_turn_status: TurnStatusHandle | None = None
|
|
116
|
+
self._agent_running: bool = False
|
|
117
|
+
self._cfg: OpenDataSciConfig | None = None
|
|
118
|
+
self._completion = (
|
|
119
|
+
completion if completion is not None else CompletionState(extra_commands=[])
|
|
120
|
+
)
|
|
121
|
+
self._paste_attachment: PasteAttachment | None = None
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def provider(self) -> str:
|
|
125
|
+
return self._base_config.provider
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def model(self) -> str:
|
|
129
|
+
return self._base_config.model
|
|
130
|
+
|
|
131
|
+
# ── Completion state delegation ───────────────────────────────────────────
|
|
132
|
+
# These properties expose CompletionState internals under the names that
|
|
133
|
+
# existed on CLIController before the extraction, so that existing tests
|
|
134
|
+
# and any external callers that relied on the old attribute names keep
|
|
135
|
+
# working without modification.
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def _completing(self) -> bool:
|
|
139
|
+
return self._completion._completing
|
|
140
|
+
|
|
141
|
+
@_completing.setter
|
|
142
|
+
def _completing(self, value: bool) -> None:
|
|
143
|
+
self._completion._completing = value
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def _comp_matches(self) -> list[str]:
|
|
147
|
+
return self._completion._matches
|
|
148
|
+
|
|
149
|
+
@_comp_matches.setter
|
|
150
|
+
def _comp_matches(self, value: list[str]) -> None:
|
|
151
|
+
self._completion._matches = value
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def _comp_displays(self) -> list[str]:
|
|
155
|
+
return self._completion._displays
|
|
156
|
+
|
|
157
|
+
@_comp_displays.setter
|
|
158
|
+
def _comp_displays(self, value: list[str]) -> None:
|
|
159
|
+
self._completion._displays = value
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def _comp_idx(self) -> int:
|
|
163
|
+
return self._completion._idx
|
|
164
|
+
|
|
165
|
+
@_comp_idx.setter
|
|
166
|
+
def _comp_idx(self, value: int) -> None:
|
|
167
|
+
self._completion._idx = value
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def _comp_at_pos(self) -> int:
|
|
171
|
+
return self._completion._at_pos
|
|
172
|
+
|
|
173
|
+
@_comp_at_pos.setter
|
|
174
|
+
def _comp_at_pos(self, value: int) -> None:
|
|
175
|
+
self._completion._at_pos = value
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def _comp_mode(self) -> str:
|
|
179
|
+
return self._completion._mode
|
|
180
|
+
|
|
181
|
+
@_comp_mode.setter
|
|
182
|
+
def _comp_mode(self, value: str) -> None:
|
|
183
|
+
self._completion._mode = value
|
|
184
|
+
|
|
185
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
async def close(self) -> None:
|
|
188
|
+
"""Release the agent sandbox and any other resources held by the controller."""
|
|
189
|
+
if self._service is not None:
|
|
190
|
+
await self._service.close()
|
|
191
|
+
await self._exit_stack.aclose()
|
|
192
|
+
|
|
193
|
+
# ── Boot ──────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
async def boot(self) -> None:
|
|
196
|
+
ui = self._ui
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
resolved_path = Path(self._workspace_path).resolve()
|
|
200
|
+
config_search_path = resolved_path if resolved_path.is_dir() else resolved_path.parent
|
|
201
|
+
mcp_servers = load_mcp_servers(config_search_path)
|
|
202
|
+
|
|
203
|
+
cfg = self._base_config.model_copy(update={"mcp_servers": mcp_servers})
|
|
204
|
+
self._cfg = cfg
|
|
205
|
+
|
|
206
|
+
agent = await self._exit_stack.enter_async_context(
|
|
207
|
+
create_agent(self._workspace_path, config=cfg)
|
|
208
|
+
)
|
|
209
|
+
workspace_path = Path(agent._workspace.get_reference())
|
|
210
|
+
self._service = OpenDataSciTuiService(
|
|
211
|
+
agent=agent,
|
|
212
|
+
sandbox=agent._sandbox,
|
|
213
|
+
workspace_path=workspace_path,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
info = CLISessionInfo.from_path(self._workspace_path, workspace_path, cfg)
|
|
217
|
+
ui.set_file_count(self._describe_data(info))
|
|
218
|
+
except FileNotFoundError:
|
|
219
|
+
hint = self._did_you_mean(self._workspace_path)
|
|
220
|
+
msg_text = (
|
|
221
|
+
f"❌ File not found: `{escape_markup(self._workspace_path)}`\n\n"
|
|
222
|
+
f"Check the path and try again.{hint}"
|
|
223
|
+
)
|
|
224
|
+
msg = ui.add_message("agent", "")
|
|
225
|
+
msg.set_content(msg_text)
|
|
226
|
+
msg.finish()
|
|
227
|
+
except PermissionError:
|
|
228
|
+
msg = ui.add_message("agent", "")
|
|
229
|
+
msg.set_content(f"❌ Permission denied: `{escape_markup(self._workspace_path)}`")
|
|
230
|
+
msg.finish()
|
|
231
|
+
except ValueError as exc:
|
|
232
|
+
msg = ui.add_message("agent", "")
|
|
233
|
+
msg.set_content(f"❌ Provider error: {exc}")
|
|
234
|
+
msg.finish()
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
msg = ui.add_message("agent", "")
|
|
237
|
+
msg.set_content(f"❌ Failed to load: {exc}")
|
|
238
|
+
msg.finish()
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _did_you_mean(workspace_path: str) -> str:
|
|
242
|
+
"""Return a 'Did you mean …?' hint if a close filename exists in the same dir."""
|
|
243
|
+
p = Path(workspace_path)
|
|
244
|
+
try:
|
|
245
|
+
siblings = [child.name for child in p.parent.iterdir()]
|
|
246
|
+
except OSError:
|
|
247
|
+
return ""
|
|
248
|
+
close = difflib.get_close_matches(p.name, siblings, n=1, cutoff=0.6)
|
|
249
|
+
if close:
|
|
250
|
+
return f"\n\nDid you mean `{p.parent / close[0]}`?"
|
|
251
|
+
return ""
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def _describe_data(info: object) -> str:
|
|
255
|
+
"""Derive a short human-readable description of the loaded data."""
|
|
256
|
+
if getattr(info, "is_directory", False):
|
|
257
|
+
count = getattr(info, "workspace_count", 0)
|
|
258
|
+
return f"{count} file{'s' if count != 1 else ''}"
|
|
259
|
+
return ""
|
|
260
|
+
|
|
261
|
+
# ── Input change ──────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
def on_input_changed(self, value: str) -> bool:
|
|
264
|
+
"""Handle input text change.
|
|
265
|
+
|
|
266
|
+
Returns ``True`` when the change was a programmatic tab-completion
|
|
267
|
+
update (caller should skip further processing).
|
|
268
|
+
"""
|
|
269
|
+
return self._completion.on_input_changed(value, self._ui)
|
|
270
|
+
|
|
271
|
+
# ── Tab completion ────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def has_completion_matches(self) -> bool:
|
|
275
|
+
"""True when the completion popup currently has items to navigate."""
|
|
276
|
+
return self._completion.has_matches
|
|
277
|
+
|
|
278
|
+
def cycle_completion(self, current_value: str, direction: int) -> bool:
|
|
279
|
+
"""Cycle completion selection up/down while the popup is visible."""
|
|
280
|
+
return self._completion.cycle(current_value, direction=direction, ui=self._ui)
|
|
281
|
+
|
|
282
|
+
def hide_completion(self) -> None:
|
|
283
|
+
self._completion.hide(self._ui)
|
|
284
|
+
|
|
285
|
+
# ── Paste attachment ──────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
def on_paste(self, text: str) -> None:
|
|
288
|
+
"""Store a multi-line paste as an attachment and show the pill in the UI."""
|
|
289
|
+
self._paste_attachment = PasteAttachment(text)
|
|
290
|
+
self._ui.show_attachment(self._paste_attachment.display_label)
|
|
291
|
+
|
|
292
|
+
def clear_paste_attachment(self) -> None:
|
|
293
|
+
"""Discard the current paste attachment (Esc handler) and hide the bar."""
|
|
294
|
+
if self._paste_attachment is not None:
|
|
295
|
+
self._paste_attachment = None
|
|
296
|
+
self._ui.hide_attachment()
|
|
297
|
+
|
|
298
|
+
# ── Submit ────────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
async def on_submit(self, raw: str) -> tuple[str, str]:
|
|
301
|
+
"""Handle input submission.
|
|
302
|
+
|
|
303
|
+
Returns ``(action, payload)`` where *action* is one of:
|
|
304
|
+
- ``"run"`` — caller should run the agent with *payload* as the query
|
|
305
|
+
- ``"quit"`` — caller should exit
|
|
306
|
+
- ``""`` — action handled internally, nothing more to do
|
|
307
|
+
"""
|
|
308
|
+
self.hide_completion()
|
|
309
|
+
|
|
310
|
+
# Always capture and clear the paste attachment at the start of
|
|
311
|
+
# submission so it is never accidentally carried into the next turn.
|
|
312
|
+
attachment = self._paste_attachment
|
|
313
|
+
self._paste_attachment = None
|
|
314
|
+
self._ui.hide_attachment()
|
|
315
|
+
|
|
316
|
+
if self._awaiting_choice:
|
|
317
|
+
if not raw:
|
|
318
|
+
return "", ""
|
|
319
|
+
if raw in {"/exit", "/reset", "/clear"}:
|
|
320
|
+
self._exit_choice_mode()
|
|
321
|
+
should_quit = await self._handle_slash(raw)
|
|
322
|
+
return ("quit" if should_quit else ""), ""
|
|
323
|
+
answer = self._handle_user_choice(raw)
|
|
324
|
+
if answer is not None:
|
|
325
|
+
return "run", answer
|
|
326
|
+
return "", ""
|
|
327
|
+
|
|
328
|
+
if not raw and attachment is None:
|
|
329
|
+
return "", ""
|
|
330
|
+
|
|
331
|
+
if raw.startswith("/"):
|
|
332
|
+
should_quit = await self._handle_slash(raw)
|
|
333
|
+
return ("quit" if should_quit else ""), ""
|
|
334
|
+
|
|
335
|
+
if self._agent_running:
|
|
336
|
+
self._ui.add_message(
|
|
337
|
+
"agent",
|
|
338
|
+
"⏳ Agent is busy. Please wait for the current response to finish.",
|
|
339
|
+
).finish()
|
|
340
|
+
return "", ""
|
|
341
|
+
|
|
342
|
+
clean_text, refs = _parse_file_refs(raw)
|
|
343
|
+
valid_refs, missing_refs = _split_existing_file_refs(refs)
|
|
344
|
+
for ref in missing_refs:
|
|
345
|
+
self._ui.add_message("agent", f"⚠️ File not found: {escape_markup(ref._path)}").finish()
|
|
346
|
+
|
|
347
|
+
if refs and not clean_text and not valid_refs and attachment is None:
|
|
348
|
+
return "", ""
|
|
349
|
+
|
|
350
|
+
display = _build_user_display(clean_text, valid_refs) if refs else escape_markup(raw)
|
|
351
|
+
agent_query = _build_agent_query(clean_text, valid_refs)
|
|
352
|
+
|
|
353
|
+
if attachment is not None:
|
|
354
|
+
display = attachment.pill_markup + ("\n" + display if display else "")
|
|
355
|
+
agent_query = (agent_query + "\n\n" if agent_query else "") + attachment.xml_tag
|
|
356
|
+
|
|
357
|
+
self._ui.add_message("user", display)
|
|
358
|
+
self._active_turn_status = self._ui.add_turn_status_bar()
|
|
359
|
+
return "run", agent_query
|
|
360
|
+
|
|
361
|
+
# ── Agent run ─────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
async def run_agent(self, query: str) -> None:
|
|
364
|
+
if self._service is None:
|
|
365
|
+
self._ui.add_message(
|
|
366
|
+
"agent", "⚠️ Still loading — please wait a moment and try again."
|
|
367
|
+
).finish()
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
self._agent_running = True
|
|
371
|
+
presenter = _TurnPresenter(self._ui)
|
|
372
|
+
try:
|
|
373
|
+
async for event in self._service.astream(query):
|
|
374
|
+
if not isinstance(event, BaseAgentStreamEvent):
|
|
375
|
+
logger.warning("astream() yielded unexpected type %r; skipping", type(event))
|
|
376
|
+
continue
|
|
377
|
+
self._dispatch_stream_event(event, presenter)
|
|
378
|
+
if isinstance(event, (ResponseEvent, ErrorEvent)):
|
|
379
|
+
break
|
|
380
|
+
except Exception as exc:
|
|
381
|
+
presenter.handle_exception(exc)
|
|
382
|
+
finally:
|
|
383
|
+
self._agent_running = False
|
|
384
|
+
presenter.cleanup()
|
|
385
|
+
if self._active_turn_status is not None:
|
|
386
|
+
self._active_turn_status.stop()
|
|
387
|
+
self._active_turn_status = None
|
|
388
|
+
if not self._awaiting_choice:
|
|
389
|
+
self._ui.set_input_placeholder("Ask a question about your data…")
|
|
390
|
+
self._ui.add_divider()
|
|
391
|
+
|
|
392
|
+
def _dispatch_stream_event(
|
|
393
|
+
self, event: BaseAgentStreamEvent, presenter: _TurnPresenter
|
|
394
|
+
) -> None:
|
|
395
|
+
"""Route a single stream event to the appropriate presenter handler."""
|
|
396
|
+
if isinstance(event, ReasoningEvent):
|
|
397
|
+
presenter.handle_reasoning(event)
|
|
398
|
+
elif isinstance(event, TokenEvent):
|
|
399
|
+
presenter.handle_token(event)
|
|
400
|
+
elif isinstance(event, ToolCommunicationEvent):
|
|
401
|
+
presenter.handle_tool_communication(event)
|
|
402
|
+
elif isinstance(event, ToolCallEvent):
|
|
403
|
+
presenter.handle_tool_call(event)
|
|
404
|
+
elif isinstance(event, WorkerDoneEvent):
|
|
405
|
+
presenter.handle_worker_done(event)
|
|
406
|
+
elif isinstance(event, SubagentEvent):
|
|
407
|
+
presenter.handle_subagent_event(event)
|
|
408
|
+
elif isinstance(event, ToolResultEvent):
|
|
409
|
+
presenter.handle_tool_result(event)
|
|
410
|
+
elif isinstance(event, UsageEvent):
|
|
411
|
+
presenter.handle_usage(event, self._active_turn_status)
|
|
412
|
+
elif isinstance(event, InputRequiredEvent):
|
|
413
|
+
self._show_choice_prompt(event.content, list(event.choices))
|
|
414
|
+
elif isinstance(event, ResponseEvent):
|
|
415
|
+
presenter.handle_response(event)
|
|
416
|
+
elif isinstance(event, ErrorEvent):
|
|
417
|
+
presenter.handle_error(event)
|
|
418
|
+
|
|
419
|
+
# ── Choice handling ───────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
def _show_choice_prompt(self, question: str, choices: list[str]) -> None:
|
|
422
|
+
labels = string.ascii_uppercase[: len(choices)]
|
|
423
|
+
other_label = (
|
|
424
|
+
string.ascii_uppercase[len(choices)]
|
|
425
|
+
if len(choices) < len(string.ascii_uppercase)
|
|
426
|
+
else None
|
|
427
|
+
)
|
|
428
|
+
lines = [
|
|
429
|
+
f"[bold {theme['warning']}]❓[/bold {theme['warning']}] "
|
|
430
|
+
f"[bold {theme['text_primary']}]{question}[/bold {theme['text_primary']}]\n"
|
|
431
|
+
]
|
|
432
|
+
for label, choice_text in zip(labels, choices):
|
|
433
|
+
lines.append(
|
|
434
|
+
f" [bold {theme['warning']}]{label}[/bold {theme['warning']}] {choice_text}"
|
|
435
|
+
)
|
|
436
|
+
if other_label is not None:
|
|
437
|
+
lines.append(
|
|
438
|
+
f" [dim {theme['text_secondary']}]{other_label}"
|
|
439
|
+
f"[/dim {theme['text_secondary']}] "
|
|
440
|
+
f"[dim {theme['text_secondary']}]Other (type your answer below)"
|
|
441
|
+
f"[/dim {theme['text_secondary']}]"
|
|
442
|
+
)
|
|
443
|
+
lines.append(
|
|
444
|
+
f" [dim {theme['text_secondary']}]Press Esc to cancel[/dim {theme['text_secondary']}]"
|
|
445
|
+
)
|
|
446
|
+
self._ui.add_message("question", "\n".join(lines)).finish()
|
|
447
|
+
self._pending_choices = list(choices)
|
|
448
|
+
self._other_choice_label = other_label
|
|
449
|
+
self._awaiting_custom_choice_input = False
|
|
450
|
+
self._awaiting_choice = True
|
|
451
|
+
prompt_labels = ", ".join(labels)
|
|
452
|
+
if other_label is not None:
|
|
453
|
+
self._ui.set_input_placeholder(
|
|
454
|
+
f"Enter {prompt_labels}, {other_label}, type your answer, or press Esc to cancel…"
|
|
455
|
+
)
|
|
456
|
+
else:
|
|
457
|
+
self._ui.set_input_placeholder(
|
|
458
|
+
"Enter a choice, type your answer, or press Esc to cancel…"
|
|
459
|
+
)
|
|
460
|
+
self._ui.add_input_class("awaiting-choice")
|
|
461
|
+
|
|
462
|
+
@property
|
|
463
|
+
def awaiting_choice(self) -> bool:
|
|
464
|
+
return self._awaiting_choice
|
|
465
|
+
|
|
466
|
+
def _exit_choice_mode(self) -> None:
|
|
467
|
+
self._awaiting_choice = False
|
|
468
|
+
self._pending_choices = []
|
|
469
|
+
self._other_choice_label = None
|
|
470
|
+
self._awaiting_custom_choice_input = False
|
|
471
|
+
self._ui.set_input_placeholder("Ask a question about your data…")
|
|
472
|
+
self._ui.remove_input_class("awaiting-choice")
|
|
473
|
+
|
|
474
|
+
def cancel_choice(self) -> str | None:
|
|
475
|
+
"""Exit choice mode and return the resume input to send to the agent.
|
|
476
|
+
|
|
477
|
+
Returns ``"cancel"`` when a choice was active (caller must pass this
|
|
478
|
+
to ``run_agent``), or ``None`` when there was nothing to cancel.
|
|
479
|
+
"""
|
|
480
|
+
if not self._awaiting_choice:
|
|
481
|
+
return None
|
|
482
|
+
self._exit_choice_mode()
|
|
483
|
+
self._ui.add_message("agent", "Choice cancelled.").finish()
|
|
484
|
+
return "cancel"
|
|
485
|
+
|
|
486
|
+
def _handle_user_choice(self, raw: str) -> str | None:
|
|
487
|
+
raw_stripped = raw.strip()
|
|
488
|
+
upper = raw_stripped.upper()
|
|
489
|
+
if (
|
|
490
|
+
self._other_choice_label is not None
|
|
491
|
+
and not self._awaiting_custom_choice_input
|
|
492
|
+
and upper == self._other_choice_label
|
|
493
|
+
):
|
|
494
|
+
self._awaiting_custom_choice_input = True
|
|
495
|
+
self._other_choice_label = None
|
|
496
|
+
self._ui.add_message("agent", "Type your answer and press Enter.").finish()
|
|
497
|
+
self._ui.set_input_placeholder("Type your answer and press Enter…")
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
pending_choices = list(self._pending_choices)
|
|
501
|
+
self._exit_choice_mode()
|
|
502
|
+
|
|
503
|
+
choice_map = {
|
|
504
|
+
label: idx for idx, label in enumerate(string.ascii_uppercase[: len(pending_choices)])
|
|
505
|
+
}
|
|
506
|
+
answer = (
|
|
507
|
+
pending_choices[choice_map[upper]]
|
|
508
|
+
if upper in choice_map and choice_map[upper] < len(pending_choices)
|
|
509
|
+
else raw_stripped
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
self._ui.add_message("user", escape_markup(raw)).finish()
|
|
513
|
+
return answer
|
|
514
|
+
|
|
515
|
+
# ── Slash command dispatch ────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
async def _handle_slash(self, cmd: str) -> bool:
|
|
518
|
+
"""Dispatch a slash command. Returns True if the app should quit."""
|
|
519
|
+
if cmd == "/exit":
|
|
520
|
+
return True
|
|
521
|
+
elif cmd == "/clear":
|
|
522
|
+
await self.clear_conv()
|
|
523
|
+
elif cmd == "/reset":
|
|
524
|
+
await self.reset()
|
|
525
|
+
elif cmd == "/compact":
|
|
526
|
+
await self.compact()
|
|
527
|
+
elif cmd == "/ls-workspace":
|
|
528
|
+
self.ls_workspace()
|
|
529
|
+
elif cmd == "/models":
|
|
530
|
+
self.show_models()
|
|
531
|
+
elif cmd == "/stop":
|
|
532
|
+
await self.stop_agent()
|
|
533
|
+
elif cmd == "/help":
|
|
534
|
+
self.show_help()
|
|
535
|
+
elif cmd == "/themes":
|
|
536
|
+
self.show_themes()
|
|
537
|
+
elif cmd == "/vars":
|
|
538
|
+
self._ui.add_message(
|
|
539
|
+
"agent",
|
|
540
|
+
"⚠️ `/vars` has been removed. Use `/help` to see available commands.",
|
|
541
|
+
).finish()
|
|
542
|
+
else:
|
|
543
|
+
self._ui.add_message(
|
|
544
|
+
"agent",
|
|
545
|
+
f"⚠️ Unknown command: `{cmd}`\n\nType `/help` to see all available commands.",
|
|
546
|
+
).finish()
|
|
547
|
+
return False
|
|
548
|
+
|
|
549
|
+
# ── Actions ───────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
async def reset(self) -> None:
|
|
552
|
+
"""Reset agent session and reload data from disk."""
|
|
553
|
+
self._ui.clear_messages()
|
|
554
|
+
if self._service is not None:
|
|
555
|
+
try:
|
|
556
|
+
await self._service.reset_session()
|
|
557
|
+
self._ui.add_message("agent", "✓ Session reset.").finish()
|
|
558
|
+
except Exception as exc:
|
|
559
|
+
self._ui.add_message("agent", f"❌ Reset failed: {exc}").finish()
|
|
560
|
+
else:
|
|
561
|
+
self._ui.add_message("agent", "Not loaded yet.").finish()
|
|
562
|
+
|
|
563
|
+
async def clear_conv(self) -> None:
|
|
564
|
+
"""Clear conversation context (preserves session variables)."""
|
|
565
|
+
self._ui.clear_messages()
|
|
566
|
+
if self._service is not None:
|
|
567
|
+
try:
|
|
568
|
+
await self._service.clear_context()
|
|
569
|
+
except Exception:
|
|
570
|
+
logger.exception("Failed to clear service context")
|
|
571
|
+
self._ui.add_message("agent", "✓ Context cleared.").finish()
|
|
572
|
+
|
|
573
|
+
async def compact(self) -> None:
|
|
574
|
+
"""Summarize the conversation and replace it with a compact context preamble."""
|
|
575
|
+
if self._service is None:
|
|
576
|
+
self._ui.add_message("agent", "Not loaded yet.").finish()
|
|
577
|
+
return
|
|
578
|
+
status = self._ui.add_message("agent", "Compacting conversation…")
|
|
579
|
+
status.set_content("Compacting conversation…")
|
|
580
|
+
compact_timer: TurnStatusHandle | None = self._ui.add_turn_status_bar()
|
|
581
|
+
try:
|
|
582
|
+
summary = await self._service.compact_chat_history()
|
|
583
|
+
except Exception as exc:
|
|
584
|
+
status.set_content(f"❌ Compact failed: {exc}")
|
|
585
|
+
if compact_timer is not None:
|
|
586
|
+
compact_timer.stop()
|
|
587
|
+
status.finish()
|
|
588
|
+
return
|
|
589
|
+
try:
|
|
590
|
+
self._ui.clear_messages()
|
|
591
|
+
compact_timer = None # removed from DOM by clear_messages()
|
|
592
|
+
self._ui.add_message(
|
|
593
|
+
"agent",
|
|
594
|
+
f"**Conversation compacted.** Carried forward:\n\n{summary}",
|
|
595
|
+
).finish()
|
|
596
|
+
finally:
|
|
597
|
+
status.finish()
|
|
598
|
+
if compact_timer is not None:
|
|
599
|
+
compact_timer.stop()
|
|
600
|
+
|
|
601
|
+
def show_models(self) -> None:
|
|
602
|
+
"""Display the primary and secondary model in use."""
|
|
603
|
+
cfg = self._cfg or self._base_config
|
|
604
|
+
self._ui.add_message(
|
|
605
|
+
"agent",
|
|
606
|
+
format_models_message(
|
|
607
|
+
cfg.provider,
|
|
608
|
+
cfg.model,
|
|
609
|
+
cfg.secondary_provider,
|
|
610
|
+
cfg.secondary_model,
|
|
611
|
+
),
|
|
612
|
+
).finish()
|
|
613
|
+
|
|
614
|
+
def show_help(self) -> None:
|
|
615
|
+
"""Display all available slash commands with descriptions."""
|
|
616
|
+
self._ui.add_message("agent", format_help_message()).finish()
|
|
617
|
+
|
|
618
|
+
def show_themes(self) -> None:
|
|
619
|
+
"""Display the list of available colour themes and mark the active one."""
|
|
620
|
+
self._ui.add_message(
|
|
621
|
+
"agent",
|
|
622
|
+
format_themes_message(_theme.active_name, _theme.THEME_DESCRIPTIONS),
|
|
623
|
+
).finish()
|
|
624
|
+
|
|
625
|
+
async def stop_agent(self) -> None:
|
|
626
|
+
"""Stop the currently running agent turn."""
|
|
627
|
+
if not self._agent_running:
|
|
628
|
+
self._ui.add_message("agent", "No agent is currently running.").finish()
|
|
629
|
+
return
|
|
630
|
+
self._ui.stop_agent()
|
|
631
|
+
if self._service is not None:
|
|
632
|
+
await self._service.rewind_turn()
|
|
633
|
+
self._ui.add_message("agent", "⏹ Agent stopped. You can continue from here.").finish()
|
|
634
|
+
|
|
635
|
+
def ls_workspace(self) -> None:
|
|
636
|
+
if self._service is None:
|
|
637
|
+
self._ui.add_message("agent", "_Not loaded yet._").finish()
|
|
638
|
+
return
|
|
639
|
+
try:
|
|
640
|
+
files = self._service.get_workspace_files()
|
|
641
|
+
except Exception as exc:
|
|
642
|
+
self._ui.add_message("agent", f"❌ {exc}").finish()
|
|
643
|
+
return
|
|
644
|
+
self._ui.show_workspace_panel(files)
|