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,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)