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
opendatasci/_tui/app.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import importlib.metadata
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from textual import events, on, work
|
|
11
|
+
from textual.app import App, ComposeResult
|
|
12
|
+
from textual.binding import Binding
|
|
13
|
+
from textual.containers import Horizontal
|
|
14
|
+
from textual.timer import Timer
|
|
15
|
+
from textual.widgets import Footer, Input
|
|
16
|
+
|
|
17
|
+
from opendatasci.configs import DEFAULT_MODEL, DEFAULT_SECONDARY_MODEL, OpenDataSciConfig
|
|
18
|
+
from opendatasci.models.providers import Provider
|
|
19
|
+
|
|
20
|
+
from . import theme as _theme
|
|
21
|
+
from .controller import CLIController, UIAdapter
|
|
22
|
+
from .widgets import (
|
|
23
|
+
AppHeader,
|
|
24
|
+
ChatPane,
|
|
25
|
+
CompletionPopup,
|
|
26
|
+
MessageBubble,
|
|
27
|
+
SmartInput,
|
|
28
|
+
ThinkingBlock,
|
|
29
|
+
ToolCallBlock,
|
|
30
|
+
TurnStatusBar,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _print_providers() -> None:
|
|
35
|
+
table = Table(title=None, show_header=True, header_style="bold")
|
|
36
|
+
table.add_column("Provider")
|
|
37
|
+
table.add_column("Default model")
|
|
38
|
+
for provider, model in DEFAULT_MODEL.items():
|
|
39
|
+
table.add_row(provider, model)
|
|
40
|
+
Console().print(table)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_version() -> str:
|
|
44
|
+
try:
|
|
45
|
+
return importlib.metadata.version("open-data-sci")
|
|
46
|
+
except importlib.metadata.PackageNotFoundError:
|
|
47
|
+
logging.getLogger(__name__).warning(
|
|
48
|
+
"open-data-sci package not found; falling back to hardcoded version '0.1.0'"
|
|
49
|
+
)
|
|
50
|
+
return "0.1.0"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OpenDataSciApp(App[None]):
|
|
54
|
+
"""OpenDataSci — full TUI for AI-powered data science."""
|
|
55
|
+
|
|
56
|
+
CSS_PATH = "styles.tcss"
|
|
57
|
+
|
|
58
|
+
BINDINGS = [
|
|
59
|
+
Binding("ctrl+c", "request_quit", "Quit"),
|
|
60
|
+
Binding("ctrl+d", "quit", "Quit", show=False),
|
|
61
|
+
Binding("ctrl+r", "reset", "Reset"),
|
|
62
|
+
Binding("ctrl+l", "clear_conv", "Clear", show=False),
|
|
63
|
+
Binding("escape", "focus_input", "Focus", show=False),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
workspace_path: str,
|
|
69
|
+
session_id: str,
|
|
70
|
+
datasci_config: OpenDataSciConfig,
|
|
71
|
+
theme: str = "default",
|
|
72
|
+
) -> None:
|
|
73
|
+
palette = _theme.THEMES.get(theme, _theme.DARK)
|
|
74
|
+
_theme.active.update(palette)
|
|
75
|
+
_theme.active_name = theme if theme in _theme.THEMES else "default"
|
|
76
|
+
if theme == "accessible":
|
|
77
|
+
self.CSS_PATH = str(Path(__file__).parent / "styles_visible.tcss") # type: ignore[misc]
|
|
78
|
+
super().__init__()
|
|
79
|
+
self._controller = CLIController(
|
|
80
|
+
ui=self, # type: ignore[arg-type]
|
|
81
|
+
workspace_path=workspace_path,
|
|
82
|
+
datasci_config=datasci_config,
|
|
83
|
+
session_id=session_id,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def compose(self) -> ComposeResult:
|
|
87
|
+
yield AppHeader(
|
|
88
|
+
version=_get_version(),
|
|
89
|
+
provider=self._controller.provider,
|
|
90
|
+
model=self._controller.model,
|
|
91
|
+
workspace=str(Path(self._controller._workspace_path).resolve()),
|
|
92
|
+
)
|
|
93
|
+
with Horizontal(id="main"):
|
|
94
|
+
yield ChatPane()
|
|
95
|
+
yield Footer()
|
|
96
|
+
|
|
97
|
+
def on_mount(self) -> None:
|
|
98
|
+
self._quit_requested = False
|
|
99
|
+
self._quit_timer: Timer | None = None
|
|
100
|
+
self.query_one("#user-input", Input).focus()
|
|
101
|
+
self._boot()
|
|
102
|
+
|
|
103
|
+
async def on_unmount(self) -> None:
|
|
104
|
+
await self._controller.close()
|
|
105
|
+
|
|
106
|
+
# ── UIAdapter implementation ──────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
def add_message(self, role: str, content: str = "") -> MessageBubble:
|
|
109
|
+
return self.query_one(ChatPane).add_message(role, content)
|
|
110
|
+
|
|
111
|
+
def add_divider(self) -> None:
|
|
112
|
+
self.query_one(ChatPane).add_divider()
|
|
113
|
+
|
|
114
|
+
def add_turn_status_bar(self) -> TurnStatusBar:
|
|
115
|
+
return self.query_one(ChatPane).add_turn_status_bar()
|
|
116
|
+
|
|
117
|
+
def add_ephemeral_block(self, communication: str, label: str, summary: str) -> ToolCallBlock:
|
|
118
|
+
return self.query_one(ChatPane).add_ephemeral_block(communication, label, summary)
|
|
119
|
+
|
|
120
|
+
def add_worker_block(self, communication: str, worker_summaries: list[str]) -> ToolCallBlock:
|
|
121
|
+
return self.query_one(ChatPane).add_worker_block(communication, worker_summaries)
|
|
122
|
+
|
|
123
|
+
def add_thinking_block(self) -> ThinkingBlock:
|
|
124
|
+
return self.query_one(ChatPane).add_thinking_block()
|
|
125
|
+
|
|
126
|
+
def clear_messages(self) -> None:
|
|
127
|
+
self.query_one(ChatPane).clear_messages()
|
|
128
|
+
|
|
129
|
+
def set_workspace(self, name: str) -> None:
|
|
130
|
+
self.query_one(AppHeader).set_workspace(name)
|
|
131
|
+
|
|
132
|
+
def set_file_count(self, description: str) -> None:
|
|
133
|
+
self.query_one(AppHeader).set_file_count(description)
|
|
134
|
+
|
|
135
|
+
def show_workspace_panel(self, files: list[str]) -> None:
|
|
136
|
+
self.query_one(ChatPane).show_workspace_panel(files)
|
|
137
|
+
|
|
138
|
+
def show_attachment(self, label: str) -> None:
|
|
139
|
+
self.query_one(ChatPane).show_attachment(label)
|
|
140
|
+
|
|
141
|
+
def hide_attachment(self) -> None:
|
|
142
|
+
self.query_one(ChatPane).hide_attachment()
|
|
143
|
+
|
|
144
|
+
def stop_agent(self) -> None:
|
|
145
|
+
self.workers.cancel_group(self, "agent")
|
|
146
|
+
|
|
147
|
+
def set_input_placeholder(self, text: str) -> None:
|
|
148
|
+
self.query_one("#user-input", Input).placeholder = text
|
|
149
|
+
|
|
150
|
+
def add_input_class(self, cls: str) -> None:
|
|
151
|
+
self.query_one("#user-input", Input).add_class(cls)
|
|
152
|
+
|
|
153
|
+
def remove_input_class(self, cls: str) -> None:
|
|
154
|
+
self.query_one("#user-input", Input).remove_class(cls)
|
|
155
|
+
|
|
156
|
+
def set_input_value(self, value: str, cursor: int | None = None) -> None:
|
|
157
|
+
inp = self.query_one("#user-input", Input)
|
|
158
|
+
inp.value = value
|
|
159
|
+
if cursor is not None:
|
|
160
|
+
inp.cursor_position = cursor
|
|
161
|
+
|
|
162
|
+
def show_completion(self, matches: list[str], selected: int) -> None:
|
|
163
|
+
self.query_one(CompletionPopup).show_matches(matches, selected)
|
|
164
|
+
|
|
165
|
+
def hide_completion(self) -> None:
|
|
166
|
+
self.query_one(CompletionPopup).hide()
|
|
167
|
+
|
|
168
|
+
# ── Event handlers ────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
@on(SmartInput.Pasted)
|
|
171
|
+
def on_paste_attachment(self, event: SmartInput.Pasted) -> None:
|
|
172
|
+
self._controller.on_paste(event._text)
|
|
173
|
+
|
|
174
|
+
@on(Input.Changed, "#user-input")
|
|
175
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
176
|
+
self._controller.on_input_changed(event.value)
|
|
177
|
+
|
|
178
|
+
@on(Input.Submitted, "#user-input")
|
|
179
|
+
async def on_submit(self, event: Input.Submitted) -> None:
|
|
180
|
+
raw = event.value.strip()
|
|
181
|
+
if raw:
|
|
182
|
+
self.query_one("#user-input", SmartInput).push_history(raw)
|
|
183
|
+
self.query_one("#user-input", Input).value = ""
|
|
184
|
+
action, query = await self._controller.on_submit(raw)
|
|
185
|
+
if action == "run":
|
|
186
|
+
self._run_agent(query)
|
|
187
|
+
elif action == "quit":
|
|
188
|
+
self.exit()
|
|
189
|
+
|
|
190
|
+
@on(events.Key)
|
|
191
|
+
def on_input_key(self, event: events.Key) -> None:
|
|
192
|
+
if event.key not in {"up", "down"}:
|
|
193
|
+
return
|
|
194
|
+
inp = self.query_one("#user-input", Input)
|
|
195
|
+
if self.focused is not inp:
|
|
196
|
+
return
|
|
197
|
+
direction = 1 if event.key == "down" else -1
|
|
198
|
+
if self._controller.has_completion_matches:
|
|
199
|
+
if self._controller.cycle_completion(inp.value, direction=direction):
|
|
200
|
+
event.stop()
|
|
201
|
+
event.prevent_default()
|
|
202
|
+
else:
|
|
203
|
+
self._controller._completing = True # suppress Input.Changed fired by value update
|
|
204
|
+
if self.query_one("#user-input", SmartInput).navigate_history(direction):
|
|
205
|
+
event.stop()
|
|
206
|
+
event.prevent_default()
|
|
207
|
+
else:
|
|
208
|
+
self._controller._completing = False
|
|
209
|
+
|
|
210
|
+
# ── @work wrappers ────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
@work
|
|
213
|
+
async def _boot(self) -> None:
|
|
214
|
+
await self._controller.boot()
|
|
215
|
+
|
|
216
|
+
@work(exclusive=True, group="agent", exit_on_error=False)
|
|
217
|
+
async def _run_agent(self, query: str) -> None:
|
|
218
|
+
await self._controller.run_agent(query)
|
|
219
|
+
|
|
220
|
+
@work
|
|
221
|
+
async def _compact(self) -> None:
|
|
222
|
+
await self._controller.compact()
|
|
223
|
+
|
|
224
|
+
# ── Action handlers ───────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
async def action_quit(self) -> None:
|
|
227
|
+
self.exit()
|
|
228
|
+
|
|
229
|
+
def action_request_quit(self) -> None:
|
|
230
|
+
if self._quit_requested:
|
|
231
|
+
self.exit()
|
|
232
|
+
return
|
|
233
|
+
self._quit_requested = True
|
|
234
|
+
self.notify("Press Ctrl+C again to quit", severity="warning", timeout=3)
|
|
235
|
+
if self._quit_timer is not None:
|
|
236
|
+
self._quit_timer.stop()
|
|
237
|
+
self._quit_timer = self.set_timer(3, self._reset_quit_request)
|
|
238
|
+
|
|
239
|
+
def _reset_quit_request(self) -> None:
|
|
240
|
+
self._quit_requested = False
|
|
241
|
+
self._quit_timer = None
|
|
242
|
+
|
|
243
|
+
async def action_reset(self) -> None:
|
|
244
|
+
await self._controller.reset()
|
|
245
|
+
|
|
246
|
+
async def action_clear_conv(self) -> None:
|
|
247
|
+
await self._controller.clear_conv()
|
|
248
|
+
|
|
249
|
+
def action_compact(self) -> None:
|
|
250
|
+
self._compact()
|
|
251
|
+
|
|
252
|
+
def action_focus_input(self) -> None:
|
|
253
|
+
self._controller.hide_completion()
|
|
254
|
+
self._controller.clear_paste_attachment()
|
|
255
|
+
if self._controller.awaiting_choice:
|
|
256
|
+
resume_input = self._controller.cancel_choice()
|
|
257
|
+
if resume_input is not None:
|
|
258
|
+
self._run_agent(resume_input)
|
|
259
|
+
self.query_one("#user-input", Input).focus()
|
|
260
|
+
|
|
261
|
+
@on(SmartInput.TabComplete)
|
|
262
|
+
def on_smart_input_tab_complete(self, event: SmartInput.TabComplete) -> None:
|
|
263
|
+
inp = self.query_one("#user-input", Input)
|
|
264
|
+
if not self._controller.cycle_completion(inp.value, direction=event._direction):
|
|
265
|
+
self.action_focus_next()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# Maps a provider name to the OpenDataSciConfig field that holds its API key.
|
|
269
|
+
# Providers that use cloud-native auth (bedrock, vertexai, ollama) have no key field.
|
|
270
|
+
_PROVIDER_KEY_FIELD: dict[Provider, str | None] = {
|
|
271
|
+
Provider.ANTHROPIC: "anthropic_api_key",
|
|
272
|
+
Provider.OPENAI: "openai_api_key",
|
|
273
|
+
Provider.GEMINI: "google_api_key",
|
|
274
|
+
Provider.AZURE: "azure_api_key",
|
|
275
|
+
Provider.OPENAI_COMPATIBLE_SERVER: "openai_api_key",
|
|
276
|
+
Provider.BEDROCK: None,
|
|
277
|
+
Provider.VERTEXAI: None,
|
|
278
|
+
Provider.OLLAMA: None,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def main() -> None:
|
|
283
|
+
load_dotenv()
|
|
284
|
+
|
|
285
|
+
parser = argparse.ArgumentParser(
|
|
286
|
+
description="OpenDataSci — AI-powered data analytics",
|
|
287
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
288
|
+
epilog="""
|
|
289
|
+
Examples:
|
|
290
|
+
opendatasci data.xlsx
|
|
291
|
+
opendatasci data.csv --provider bedrock
|
|
292
|
+
opendatasci ./data_folder --provider openai --model gpt-4o
|
|
293
|
+
opendatasci data.csv --secondary-provider openai --secondary-model gpt-4o-mini
|
|
294
|
+
opendatasci data.csv --config path/to/datasci_config.yaml
|
|
295
|
+
""",
|
|
296
|
+
)
|
|
297
|
+
parser.add_argument(
|
|
298
|
+
"workspace_or_file",
|
|
299
|
+
nargs="?",
|
|
300
|
+
default=None,
|
|
301
|
+
help="Data file or directory containing data files to work with",
|
|
302
|
+
)
|
|
303
|
+
parser.add_argument(
|
|
304
|
+
"--provider",
|
|
305
|
+
default=None,
|
|
306
|
+
choices=list(Provider),
|
|
307
|
+
help="LLM provider for the primary model (default: anthropic)",
|
|
308
|
+
)
|
|
309
|
+
parser.add_argument(
|
|
310
|
+
"--model",
|
|
311
|
+
dest="model",
|
|
312
|
+
default=None,
|
|
313
|
+
help="Primary model name (provider-specific)",
|
|
314
|
+
)
|
|
315
|
+
parser.add_argument(
|
|
316
|
+
"--secondary-provider",
|
|
317
|
+
dest="secondary_provider",
|
|
318
|
+
default=None,
|
|
319
|
+
choices=list(Provider),
|
|
320
|
+
help="LLM provider for the secondary (auxiliary) model — may differ from --provider",
|
|
321
|
+
)
|
|
322
|
+
parser.add_argument(
|
|
323
|
+
"--secondary-model",
|
|
324
|
+
dest="secondary_model",
|
|
325
|
+
default=None,
|
|
326
|
+
help="Secondary model name (resolved against --secondary-provider or --provider)",
|
|
327
|
+
)
|
|
328
|
+
parser.add_argument(
|
|
329
|
+
"--api-key",
|
|
330
|
+
dest="api_key",
|
|
331
|
+
default=None,
|
|
332
|
+
help="API key for the primary provider (or set via environment variable)",
|
|
333
|
+
)
|
|
334
|
+
parser.add_argument(
|
|
335
|
+
"--theme",
|
|
336
|
+
choices=list(_theme.THEMES.keys()),
|
|
337
|
+
default="default",
|
|
338
|
+
help=(
|
|
339
|
+
"Colour palette. Choices: "
|
|
340
|
+
+ ", ".join(_theme.THEMES.keys())
|
|
341
|
+
+ ". Run `/themes` inside the TUI for descriptions."
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
parser.add_argument(
|
|
345
|
+
"--config",
|
|
346
|
+
default=None,
|
|
347
|
+
metavar="FILE",
|
|
348
|
+
help=(
|
|
349
|
+
"Path to a YAML file containing OpenDataSciConfig fields. "
|
|
350
|
+
"Explicit TUI flags take precedence over values in the file."
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
parser.add_argument(
|
|
354
|
+
"--list-providers",
|
|
355
|
+
action="store_true",
|
|
356
|
+
help="List supported providers and their default models, then exit",
|
|
357
|
+
)
|
|
358
|
+
parser.add_argument("--version", action="version", version=f"OpenDataSci {_get_version()}")
|
|
359
|
+
args = parser.parse_args()
|
|
360
|
+
|
|
361
|
+
if args.list_providers:
|
|
362
|
+
_print_providers()
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
if args.workspace_or_file is None:
|
|
366
|
+
parser.error("the following arguments are required: path")
|
|
367
|
+
|
|
368
|
+
# Build OpenDataSciConfig: YAML file provides the base; explicit TUI flags override.
|
|
369
|
+
if args.config:
|
|
370
|
+
datasci_config = OpenDataSciConfig.from_yaml(args.config)
|
|
371
|
+
overrides: dict[str, object] = {}
|
|
372
|
+
if args.provider is not None:
|
|
373
|
+
overrides["provider"] = args.provider
|
|
374
|
+
if args.model is not None:
|
|
375
|
+
overrides["model"] = args.model
|
|
376
|
+
if args.secondary_provider is not None:
|
|
377
|
+
overrides["secondary_provider"] = args.secondary_provider
|
|
378
|
+
if args.secondary_model is not None:
|
|
379
|
+
overrides["secondary_model"] = args.secondary_model
|
|
380
|
+
if args.api_key is not None:
|
|
381
|
+
effective_provider = str(args.provider or datasci_config.provider)
|
|
382
|
+
key_field = _PROVIDER_KEY_FIELD.get(Provider(effective_provider))
|
|
383
|
+
if key_field:
|
|
384
|
+
overrides[key_field] = args.api_key
|
|
385
|
+
else:
|
|
386
|
+
parser.error(
|
|
387
|
+
f"--api-key is not supported for provider '{effective_provider}' "
|
|
388
|
+
f"(uses cloud-native authentication)"
|
|
389
|
+
)
|
|
390
|
+
if overrides:
|
|
391
|
+
datasci_config = datasci_config.model_copy(update=overrides)
|
|
392
|
+
else:
|
|
393
|
+
provider: Provider = args.provider or Provider.ANTHROPIC
|
|
394
|
+
resolved_secondary_provider: Provider = args.secondary_provider or provider
|
|
395
|
+
kwargs: dict[str, object] = {
|
|
396
|
+
"provider": provider,
|
|
397
|
+
"model": args.model or DEFAULT_MODEL[provider],
|
|
398
|
+
"secondary_provider": resolved_secondary_provider,
|
|
399
|
+
"secondary_model": args.secondary_model
|
|
400
|
+
or DEFAULT_SECONDARY_MODEL[resolved_secondary_provider],
|
|
401
|
+
}
|
|
402
|
+
if args.api_key is not None:
|
|
403
|
+
key_field = _PROVIDER_KEY_FIELD.get(provider)
|
|
404
|
+
if key_field:
|
|
405
|
+
kwargs[key_field] = args.api_key
|
|
406
|
+
else:
|
|
407
|
+
parser.error(
|
|
408
|
+
f"--api-key is not supported for provider '{provider}' "
|
|
409
|
+
f"(uses cloud-native authentication)"
|
|
410
|
+
)
|
|
411
|
+
datasci_config = OpenDataSciConfig(**kwargs) # type: ignore[arg-type]
|
|
412
|
+
|
|
413
|
+
session_id = uuid.uuid4().hex
|
|
414
|
+
|
|
415
|
+
OpenDataSciApp(
|
|
416
|
+
workspace_path=args.workspace_or_file,
|
|
417
|
+
session_id=session_id,
|
|
418
|
+
datasci_config=datasci_config,
|
|
419
|
+
theme=args.theme,
|
|
420
|
+
).run()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# Register OpenDataSciApp as a virtual subclass of UIAdapter to avoid the metaclass
|
|
424
|
+
# conflict between Textual's _MessagePumpMeta and ABCMeta.
|
|
425
|
+
UIAdapter.register(OpenDataSciApp)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
if __name__ == "__main__":
|
|
429
|
+
main()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Slash command registry and display-text formatters for the OpenDataSci TUI."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from opendatasci.models.providers import Provider
|
|
6
|
+
|
|
7
|
+
SLASH_COMMANDS: list[str] = [
|
|
8
|
+
"/clear",
|
|
9
|
+
"/compact",
|
|
10
|
+
"/exit",
|
|
11
|
+
"/help",
|
|
12
|
+
"/ls-workspace",
|
|
13
|
+
"/models",
|
|
14
|
+
"/reset",
|
|
15
|
+
"/stop",
|
|
16
|
+
"/themes",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
SLASH_COMMAND_DESCRIPTIONS: dict[str, str] = {
|
|
20
|
+
"/clear": "Clear conversation context",
|
|
21
|
+
"/compact": "Summarize conversation history",
|
|
22
|
+
"/exit": "Exit OpenDataSci",
|
|
23
|
+
"/help": "Show all commands",
|
|
24
|
+
"/ls-workspace": "List workspace files",
|
|
25
|
+
"/models": "Show models in use",
|
|
26
|
+
"/reset": "Reset agent session",
|
|
27
|
+
"/stop": "Stop the running agent",
|
|
28
|
+
"/themes": "List available colour themes",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_PROVIDER_DISPLAY: dict[Provider, str] = {
|
|
32
|
+
Provider.ANTHROPIC: "Anthropic",
|
|
33
|
+
Provider.OPENAI: "OpenAI",
|
|
34
|
+
Provider.BEDROCK: "AWS Bedrock",
|
|
35
|
+
Provider.GEMINI: "Google",
|
|
36
|
+
Provider.VERTEXAI: "Google Vertex AI",
|
|
37
|
+
Provider.AZURE: "Azure OpenAI",
|
|
38
|
+
Provider.OLLAMA: "Ollama",
|
|
39
|
+
Provider.OPENAI_COMPATIBLE_SERVER: "OpenAI-compatible server",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fmt_model(provider: str, model_id: str) -> str:
|
|
44
|
+
try:
|
|
45
|
+
provider_label = _PROVIDER_DISPLAY[Provider(provider)]
|
|
46
|
+
except (KeyError, ValueError):
|
|
47
|
+
provider_label = provider.title()
|
|
48
|
+
m = re.search(r"claude-([a-z]+)-(\d+)-(\d+)", model_id)
|
|
49
|
+
if m:
|
|
50
|
+
variant, major, minor = m.groups()
|
|
51
|
+
return f"{provider_label} Claude {variant.title()} {major}.{minor}"
|
|
52
|
+
return f"{provider_label} {model_id}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_models_message(
|
|
56
|
+
primary_provider: str, model: str, secondary_provider: str, secondary_model: str
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Return the Markdown text shown by the /models command."""
|
|
59
|
+
lines = [
|
|
60
|
+
"## Models\n",
|
|
61
|
+
f"- **Primary Model** : {_fmt_model(primary_provider, model)}",
|
|
62
|
+
f"- **Secondary Model** : {_fmt_model(secondary_provider, secondary_model)}",
|
|
63
|
+
]
|
|
64
|
+
return "\n".join(lines)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_help_message() -> str:
|
|
68
|
+
"""Return the Markdown text shown by the /help command."""
|
|
69
|
+
lines = [
|
|
70
|
+
"## Available Commands\n",
|
|
71
|
+
"- **/clear** — Clear the conversation (preserves session variables)",
|
|
72
|
+
"- **/compact** — Summarize and compress the conversation history",
|
|
73
|
+
"- **/exit** — Exit OpenDataSci",
|
|
74
|
+
"- **/help** — Show this help message",
|
|
75
|
+
"- **/ls-workspace** — List files in the workspace",
|
|
76
|
+
"- **/models** — Show the primary and secondary model in use",
|
|
77
|
+
"- **/reset** — Reset the agent session and reload data from disk",
|
|
78
|
+
"- **/stop** — Stop the running agent (future messages pick up where it left off)",
|
|
79
|
+
"- **/themes** — List available colour themes (selected at launch with `--theme`)",
|
|
80
|
+
]
|
|
81
|
+
lines.append("\n**Tip:** Type `/` to see commands via autocomplete, or `@` to attach a file.")
|
|
82
|
+
return "\n".join(lines)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def format_themes_message(active_name: str, themes: dict[str, str]) -> str:
|
|
86
|
+
"""Return the Markdown text shown by the /themes command.
|
|
87
|
+
|
|
88
|
+
`themes` is a mapping of theme name to a one-line description.
|
|
89
|
+
"""
|
|
90
|
+
lines = ["## Colour Themes\n"]
|
|
91
|
+
for name, description in themes.items():
|
|
92
|
+
marker = " *(active)*" if name == active_name else ""
|
|
93
|
+
lines.append(f"- **{name}**{marker} — {description}")
|
|
94
|
+
lines.append("\nSwitch themes by relaunching with `--theme <name>`.")
|
|
95
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Tab-completion state machine for the OpenDataSci TUI input bar.
|
|
2
|
+
|
|
3
|
+
Manages slash-command and @file-path completion independently of Textual
|
|
4
|
+
widgets, so it can be unit-tested without a running app.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from .adapter import UIAdapter
|
|
10
|
+
from .commands import SLASH_COMMAND_DESCRIPTIONS, SLASH_COMMANDS
|
|
11
|
+
from .file_refs import _discover_files, _find_at_fragment, _find_slash_fragment
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CompletionState:
|
|
17
|
+
"""Encapsulates all mutable state for the input-bar completion popup.
|
|
18
|
+
|
|
19
|
+
Owned by ``CLIController``; delegates UI updates through ``UIAdapter``
|
|
20
|
+
so the logic remains fully testable without a Textual widget tree.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, extra_commands: list[str] | None = None) -> None:
|
|
24
|
+
self._matches: list[str] = []
|
|
25
|
+
self._displays: list[str] = []
|
|
26
|
+
self._idx: int = -1
|
|
27
|
+
self._at_pos: int = -1
|
|
28
|
+
self._mode: str = "file"
|
|
29
|
+
# Set to True by cycle() before it changes the input value, so that
|
|
30
|
+
# the resulting on_input_changed callback knows to ignore the event.
|
|
31
|
+
self._completing: bool = False
|
|
32
|
+
# Cache the last @-fragment so _discover_files is not called on every
|
|
33
|
+
# keystroke when the fragment hasn't changed.
|
|
34
|
+
self._last_at_fragment: str | None = None
|
|
35
|
+
self._cached_at_matches: list[str] = []
|
|
36
|
+
self._all_commands: list[str] = SLASH_COMMANDS + list(extra_commands or [])
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def has_matches(self) -> bool:
|
|
40
|
+
"""True when the completion popup currently has items to navigate."""
|
|
41
|
+
return bool(self._matches)
|
|
42
|
+
|
|
43
|
+
def on_input_changed(self, value: str, ui: UIAdapter) -> bool:
|
|
44
|
+
"""Handle an input-text change.
|
|
45
|
+
|
|
46
|
+
Returns ``True`` when the change was a programmatic tab-completion
|
|
47
|
+
update (the caller should skip further processing).
|
|
48
|
+
"""
|
|
49
|
+
if self._completing:
|
|
50
|
+
self._completing = False
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
slash_frag = _find_slash_fragment(value)
|
|
54
|
+
if slash_frag is not None:
|
|
55
|
+
matches = [cmd for cmd in self._all_commands if cmd.startswith(slash_frag)]
|
|
56
|
+
if matches and not (len(matches) == 1 and slash_frag == matches[0]):
|
|
57
|
+
self._matches = matches
|
|
58
|
+
self._displays = [
|
|
59
|
+
f"{cmd} {SLASH_COMMAND_DESCRIPTIONS.get(cmd, '')}" for cmd in matches
|
|
60
|
+
]
|
|
61
|
+
self._idx = -1
|
|
62
|
+
self._at_pos = -1
|
|
63
|
+
self._mode = "slash"
|
|
64
|
+
ui.show_completion(self._displays, self._idx)
|
|
65
|
+
else:
|
|
66
|
+
self.hide(ui)
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
result = _find_at_fragment(value)
|
|
70
|
+
if result is None:
|
|
71
|
+
self.hide(ui)
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
fragment, at_pos = result
|
|
75
|
+
if fragment != self._last_at_fragment:
|
|
76
|
+
self._cached_at_matches = _discover_files(fragment)
|
|
77
|
+
self._last_at_fragment = fragment
|
|
78
|
+
matches = self._cached_at_matches
|
|
79
|
+
if not matches:
|
|
80
|
+
self.hide(ui)
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
self._matches = matches
|
|
84
|
+
self._idx = -1
|
|
85
|
+
self._at_pos = at_pos
|
|
86
|
+
self._mode = "file"
|
|
87
|
+
ui.show_completion(matches, self._idx)
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
def cycle(self, current_value: str, direction: int, ui: UIAdapter) -> bool:
|
|
91
|
+
"""Cycle the completion selection by ``direction`` (+1 down, -1 up).
|
|
92
|
+
|
|
93
|
+
Returns ``True`` if a completion was applied (caller should NOT call
|
|
94
|
+
focus_next); ``False`` if there are no completions to cycle.
|
|
95
|
+
"""
|
|
96
|
+
if not self._matches:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
if direction < 0 and self._idx == -1:
|
|
100
|
+
self._idx = len(self._matches) - 1
|
|
101
|
+
else:
|
|
102
|
+
self._idx = (self._idx + direction) % len(self._matches)
|
|
103
|
+
|
|
104
|
+
match = self._matches[self._idx]
|
|
105
|
+
|
|
106
|
+
if self._mode == "slash":
|
|
107
|
+
self._completing = True
|
|
108
|
+
ui.set_input_value(match, len(match))
|
|
109
|
+
ui.show_completion(self._displays, self._idx)
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
after = current_value[self._at_pos + 1 :]
|
|
113
|
+
space_pos = after.find(" ")
|
|
114
|
+
rest = after[space_pos:] if space_pos != -1 else ""
|
|
115
|
+
new_value = current_value[: self._at_pos + 1] + match + rest
|
|
116
|
+
cursor = self._at_pos + 1 + len(match)
|
|
117
|
+
self._completing = True
|
|
118
|
+
ui.set_input_value(new_value, cursor)
|
|
119
|
+
ui.show_completion(self._matches, self._idx)
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
def hide(self, ui: UIAdapter) -> None:
|
|
123
|
+
"""Clear completion state and hide the popup (no-op if already hidden)."""
|
|
124
|
+
was_showing = bool(self._matches)
|
|
125
|
+
self._matches = []
|
|
126
|
+
self._displays = []
|
|
127
|
+
self._idx = -1
|
|
128
|
+
self._at_pos = -1
|
|
129
|
+
self._mode = "file"
|
|
130
|
+
self._last_at_fragment = None
|
|
131
|
+
self._cached_at_matches = []
|
|
132
|
+
# Only update the UI when the popup was actually visible, to avoid
|
|
133
|
+
# spurious re-renders on every normal keystroke.
|
|
134
|
+
if not was_showing:
|
|
135
|
+
return
|
|
136
|
+
try:
|
|
137
|
+
ui.hide_completion()
|
|
138
|
+
except Exception:
|
|
139
|
+
logger.exception("Failed to hide completion popup")
|