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.
Files changed (85) hide show
  1. open_data_sci-0.1.0.dist-info/METADATA +629 -0
  2. open_data_sci-0.1.0.dist-info/RECORD +85 -0
  3. open_data_sci-0.1.0.dist-info/WHEEL +4 -0
  4. open_data_sci-0.1.0.dist-info/entry_points.txt +2 -0
  5. open_data_sci-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. opendatasci/__init__.py +47 -0
  7. opendatasci/_tui/__init__.py +1 -0
  8. opendatasci/_tui/adapter.py +102 -0
  9. opendatasci/_tui/app.py +429 -0
  10. opendatasci/_tui/commands.py +95 -0
  11. opendatasci/_tui/completion.py +139 -0
  12. opendatasci/_tui/controller.py +644 -0
  13. opendatasci/_tui/file_refs.py +153 -0
  14. opendatasci/_tui/models.py +4 -0
  15. opendatasci/_tui/presenter.py +259 -0
  16. opendatasci/_tui/service.py +78 -0
  17. opendatasci/_tui/session.py +53 -0
  18. opendatasci/_tui/styles.tcss +248 -0
  19. opendatasci/_tui/styles_visible.tcss +245 -0
  20. opendatasci/_tui/theme.py +113 -0
  21. opendatasci/_tui/tools_display.py +86 -0
  22. opendatasci/_tui/widgets.py +1001 -0
  23. opendatasci/_utils/__init__.py +0 -0
  24. opendatasci/_utils/async_utils.py +11 -0
  25. opendatasci/_utils/data_formats.py +135 -0
  26. opendatasci/_utils/hash_utils.py +52 -0
  27. opendatasci/_utils/langchain_utils.py +155 -0
  28. opendatasci/_utils/streaming_utils.py +23 -0
  29. opendatasci/agents/__init__.py +12 -0
  30. opendatasci/agents/agents.py +515 -0
  31. opendatasci/agents/agents_factory.py +71 -0
  32. opendatasci/agents/chat_memory.py +397 -0
  33. opendatasci/agents/graphs.py +84 -0
  34. opendatasci/agents/nodes.py +74 -0
  35. opendatasci/agents/states.py +36 -0
  36. opendatasci/agents/turn_memory.py +124 -0
  37. opendatasci/configs.py +275 -0
  38. opendatasci/context/__init__.py +7 -0
  39. opendatasci/context/base.py +56 -0
  40. opendatasci/context/local.py +236 -0
  41. opendatasci/models/__init__.py +7 -0
  42. opendatasci/models/anthropic.py +40 -0
  43. opendatasci/models/aws.py +86 -0
  44. opendatasci/models/factory.py +179 -0
  45. opendatasci/models/google.py +79 -0
  46. opendatasci/models/local.py +79 -0
  47. opendatasci/models/microsoft.py +62 -0
  48. opendatasci/models/openai.py +49 -0
  49. opendatasci/models/providers.py +12 -0
  50. opendatasci/prompts/__init__.py +5 -0
  51. opendatasci/prompts/builders.py +85 -0
  52. opendatasci/prompts/caching.py +42 -0
  53. opendatasci/prompts/message_templates.py +7 -0
  54. opendatasci/prompts/prompt_templates.py +227 -0
  55. opendatasci/resources/skills/competitive_data_science.md +241 -0
  56. opendatasci/resources/skills/data_science.md +55 -0
  57. opendatasci/resources/skills/data_science_education.md +42 -0
  58. opendatasci/resources/skills/deep_learning.md +205 -0
  59. opendatasci/resources/skills/machine_learning.md +68 -0
  60. opendatasci/resources/skills/quantitative_analysis.md +45 -0
  61. opendatasci/sandbox/__init__.py +14 -0
  62. opendatasci/sandbox/_runner.py +114 -0
  63. opendatasci/sandbox/base.py +170 -0
  64. opendatasci/sandbox/srt.py +490 -0
  65. opendatasci/skills/__init__.py +9 -0
  66. opendatasci/skills/base.py +28 -0
  67. opendatasci/skills/local.py +131 -0
  68. opendatasci/streaming/__init__.py +37 -0
  69. opendatasci/streaming/events.py +159 -0
  70. opendatasci/streaming/processors.py +387 -0
  71. opendatasci/tools/__init__.py +58 -0
  72. opendatasci/tools/coding.py +261 -0
  73. opendatasci/tools/critic.py +136 -0
  74. opendatasci/tools/dataset_info.py +391 -0
  75. opendatasci/tools/factory.py +172 -0
  76. opendatasci/tools/mcp.py +179 -0
  77. opendatasci/tools/planning.py +88 -0
  78. opendatasci/tools/skills.py +90 -0
  79. opendatasci/tools/user_interaction.py +54 -0
  80. opendatasci/tools/web.py +236 -0
  81. opendatasci/tools/workers.py +237 -0
  82. opendatasci/tools/workspace.py +55 -0
  83. opendatasci/workspace/__init__.py +9 -0
  84. opendatasci/workspace/base.py +20 -0
  85. opendatasci/workspace/local.py +25 -0
@@ -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")