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,1001 @@
1
+ """Textual widgets for OpenDataSci TUI v2."""
2
+
3
+ import bisect
4
+ import logging
5
+ import math
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from rich.highlighter import Highlighter
10
+ from rich.markup import escape
11
+ from rich.rule import Rule
12
+ from rich.text import Text
13
+ from textual import events
14
+ from textual.app import ComposeResult
15
+ from textual.binding import Binding
16
+ from textual.containers import Horizontal, ScrollableContainer, Vertical
17
+ from textual.message import Message
18
+ from textual.timer import Timer
19
+ from textual.widget import Widget
20
+ from textual.widgets import Input, Static
21
+ from textual.widgets import Markdown as TUIMarkdown
22
+
23
+ try:
24
+ from textual.widgets import Image as _TUIImage # type: ignore[attr-defined]
25
+ except ImportError:
26
+ _TUIImage = None
27
+
28
+ from .adapter import EphemeralHandle, MessageHandle, ThinkingHandle, TurnStatusHandle
29
+ from .commands import SLASH_COMMANDS, _fmt_model
30
+ from .models import SPINNER, SPINNER_INTERVAL
31
+ from .theme import active as theme
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _BUBBLE_FLUSH_INTERVAL = 0.25 # seconds — caps Markdown rebuilds at 4/sec during streaming
36
+
37
+
38
+ class CommandHighlighter(Highlighter):
39
+ """Highlight a valid (or partial) slash command at the start of input."""
40
+
41
+ _sorted_commands: list[str] = sorted(SLASH_COMMANDS)
42
+
43
+ def highlight(self, text: Text) -> None:
44
+ plain = text.plain
45
+ if not plain.startswith("/"):
46
+ return
47
+ token = plain.split()[0] if plain.split() else plain
48
+ is_valid = token in SLASH_COMMANDS
49
+ if not is_valid:
50
+ idx = bisect.bisect_left(self._sorted_commands, token)
51
+ is_prefix = idx < len(self._sorted_commands) and self._sorted_commands[idx].startswith(
52
+ token
53
+ )
54
+ else:
55
+ is_prefix = False
56
+ if is_valid or is_prefix:
57
+ text.stylize(f"bold {theme['accent']}", 0, len(token))
58
+
59
+
60
+ class AppHeader(Widget):
61
+ """Docked top bar: logo left, version/workspace info right."""
62
+
63
+ DEFAULT_CSS = """
64
+ AppHeader {
65
+ dock: top;
66
+ height: 5;
67
+ background: #0d1117;
68
+ border-bottom: solid #1a2030;
69
+ }
70
+ #header-layout { layout: horizontal; height: 5; }
71
+ #header-logo {
72
+ width: 21;
73
+ height: 5;
74
+ content-align: center middle;
75
+ }
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ version: str,
81
+ provider: str,
82
+ model: str,
83
+ workspace: str,
84
+ workspace_name: str | None = None,
85
+ ) -> None:
86
+ super().__init__()
87
+ self._version = version
88
+ self._provider = provider
89
+ self._model = model
90
+ self._workspace = workspace
91
+ self._workspace_name = workspace_name
92
+ self._file_count: str = ""
93
+ _logo_path = Path(__file__).parents[4] / "docs" / "logo.png"
94
+ self._use_image = _TUIImage is not None and _logo_path.exists()
95
+ self._logo_path = _logo_path
96
+
97
+ def compose(self) -> ComposeResult:
98
+ with Horizontal(id="header-layout"):
99
+ if self._use_image:
100
+ yield _TUIImage(self._logo_path, id="header-logo")
101
+ else:
102
+ yield Static(id="header-logo")
103
+ yield Static(id="header-info")
104
+
105
+ def on_mount(self) -> None:
106
+ if not self._use_image:
107
+ self._render_logo()
108
+ self._render_info()
109
+
110
+ def _render_logo(self) -> None:
111
+ bold = f"bold {theme['logo']}"
112
+ t = Text()
113
+ t.append("OpenDataSci", style=bold)
114
+ self.query_one("#header-logo", Static).update(t)
115
+
116
+ def _render_info(self) -> None:
117
+ lbl = theme["text_secondary"]
118
+ t = Text()
119
+ t.append("Version ", style=lbl)
120
+ version_str = f"v{self._version}"
121
+ t.append(version_str, style=f"bold {theme['logo']}")
122
+ t.append("\n")
123
+ t.append("Workspace ", style=lbl)
124
+ t.append(self._workspace, style=theme["text_primary"])
125
+ if self._file_count:
126
+ t.append(f" ({self._file_count})", style=theme["text_secondary"])
127
+ if self._workspace_name:
128
+ t.append(" Workspace ", style=lbl)
129
+ t.append(self._workspace_name, style=theme["accent"])
130
+ t.append("\n")
131
+ t.append("Model ", style=lbl)
132
+ t.append(_fmt_model(self._provider, self._model), style=theme["text_primary"])
133
+ self.query_one("#header-info", Static).update(t)
134
+
135
+ def set_workspace(self, name: str | None) -> None:
136
+ self._workspace_name = name
137
+ self._render_info()
138
+
139
+ def set_file_count(self, description: str) -> None:
140
+ self._file_count = description
141
+ self._render_info()
142
+
143
+
144
+ class TurnStatusBar(Static):
145
+ """Inline status bar appended at the end of the conversation during an agent turn."""
146
+
147
+ DEFAULT_CSS = """
148
+ TurnStatusBar {
149
+ height: auto;
150
+ padding: 0 2;
151
+ margin-bottom: 0;
152
+ text-align: right;
153
+ }
154
+ """
155
+
156
+ def __init__(self, *args: object, **kwargs: object) -> None:
157
+ super().__init__(*args, **kwargs) # type: ignore[arg-type]
158
+ # Initialise instance variables so that stop() / on_unmount() are safe
159
+ # to call even if on_mount() has not yet fired (e.g. when a stale
160
+ # bar widget is removed by add_turn_status_bar's cleanup loop right
161
+ # after mount() but before the event-loop dispatches on_mount).
162
+ self._stopped: bool = False # False = running once on_mount fires
163
+ self._mounted: bool = False # True only after on_mount has run
164
+ self._start: float = 0.0
165
+ self._interval: Timer | None = None
166
+ self._context_tokens: int | None = None
167
+ self._cached_tokens: int | None = None
168
+
169
+ def on_mount(self) -> None:
170
+ self._mounted = True
171
+ self._start = time.monotonic()
172
+ self._interval = self.set_interval(1, self._tick)
173
+ self._stopped = False
174
+ self._tick()
175
+
176
+ def _fmt(self, s: int) -> str:
177
+ if s < 60:
178
+ return f"{s}s"
179
+ mins, secs = divmod(s, 60)
180
+ return f"{mins}min {secs:02d}s" if secs else f"{mins}min"
181
+
182
+ @staticmethod
183
+ def _fmt_tokens(n: int) -> str:
184
+ """Format token count as truncated-to-one-decimal thousands, e.g. 3250 → '3.2k'."""
185
+ k = math.floor(n / 100) / 10
186
+ return f"{k:.1f}k"
187
+
188
+ def _context_suffix(self) -> str:
189
+ if self._context_tokens is None:
190
+ return ""
191
+ size = self._fmt_tokens(self._context_tokens)
192
+ if self._cached_tokens is None:
193
+ return f" | Context: {size} tokens"
194
+ pct = math.ceil(self._cached_tokens / max(self._context_tokens, 1) * 1000) / 10
195
+ return f" | Context: {size} tokens ({pct:.1f}% cached)"
196
+
197
+ def _tick(self) -> None:
198
+ s = int(time.monotonic() - self._start)
199
+ label = f"Working for {self._fmt(s)}{self._context_suffix()}"
200
+ self.update(f"[{theme['text_muted']}]{label}[/{theme['text_muted']}]")
201
+
202
+ def update_context(self, context_tokens: int | None, cached_tokens: int | None) -> None:
203
+ """Update context size and cached token count and re-render the label."""
204
+ self._context_tokens = context_tokens
205
+ self._cached_tokens = cached_tokens
206
+ if not self._stopped and self._mounted:
207
+ self._tick()
208
+
209
+ def stop(self) -> None:
210
+ if self._stopped or not self._mounted:
211
+ return
212
+ self._stopped = True
213
+ if self._interval is not None:
214
+ self._interval.stop()
215
+ s = int(time.monotonic() - self._start)
216
+ label = f"Worked for {self._fmt(s)}{self._context_suffix()}"
217
+ self.update(f"[{theme['text_muted']}]{label}[/{theme['text_muted']}]")
218
+
219
+ def on_unmount(self) -> None:
220
+ # Guard against removal before stop() was explicitly called, and also
221
+ # against removal before on_mount() ever fired (_mounted is False).
222
+ if self._mounted and not self._stopped:
223
+ self._stopped = True
224
+ if self._interval is not None:
225
+ self._interval.stop()
226
+
227
+
228
+ class MessageBubble(Widget):
229
+ """A single chat message — user, agent (streaming), or thinking."""
230
+
231
+ def __init__(self, role: str, content: str = "") -> None:
232
+ super().__init__()
233
+ self._role = role
234
+ self._content = content
235
+ self._spin_idx = 0
236
+ self._spin_label: str = "Thinking"
237
+ self._spin_timer: Timer | None = None
238
+ self._inner: Static | TUIMarkdown | None = None
239
+ self._summary_text: str | None = None
240
+ self._flush_timer: Timer | None = None # rate-limit Markdown rebuilds
241
+ self._dirty: bool = False
242
+ self._flush_scheduled: bool = False # at most one call_after_refresh pending
243
+ self.add_class(role)
244
+
245
+ def compose(self) -> ComposeResult:
246
+ inner: Static | TUIMarkdown
247
+ if self._role == "agent":
248
+ # Always start with an empty Markdown widget. All rendering goes
249
+ # through _flush_agent so there is only ever one update() task in
250
+ # flight, avoiding the race where TUIMarkdown._on_mount's own
251
+ # update() task and _flush_agent's update() task both mount content
252
+ # and produce duplicate text.
253
+ md = TUIMarkdown("")
254
+ md.code_dark_theme = "github-dark" # type: ignore[attr-defined]
255
+ inner = md
256
+ else:
257
+ inner = Static("")
258
+ self._inner = inner
259
+ yield inner
260
+
261
+ def on_mount(self) -> None:
262
+ self._spin_timer = (
263
+ self.set_interval(SPINNER_INTERVAL, self._spin_tick)
264
+ if self._role == "thinking"
265
+ else None
266
+ )
267
+ self._refresh_content()
268
+ # If the bubble already has content (set before mount completed) mark it
269
+ # dirty so the flush below will render it. This covers the common
270
+ # pattern add_message("agent", text).finish() where finish() runs before
271
+ # compose/on_mount, as well as the response-event path where the full
272
+ # answer is passed directly to the constructor.
273
+ if self._role == "agent":
274
+ if self._content:
275
+ self._dirty = True
276
+ if self._dirty:
277
+ self._flush_scheduled = False # force a new call even if one was pending
278
+ self._schedule_final_flush()
279
+
280
+ def _spin_tick(self) -> None:
281
+ self._spin_idx = (self._spin_idx + 1) % len(SPINNER)
282
+ self._refresh_content()
283
+
284
+ def _refresh_content(self) -> None:
285
+ inner = self._inner
286
+ if inner is None:
287
+ return
288
+ role = self._role
289
+ content = self._content
290
+ if role == "agent":
291
+ # Agent rendering happens in two places:
292
+ # 1. compose() seeds the Markdown widget with self._content so
293
+ # content present at construction (or accumulated before
294
+ # compose ran) is rendered automatically by Markdown's own
295
+ # mount hook.
296
+ # 2. _flush_agent() applies subsequent updates (streaming tokens,
297
+ # set_content, finish) at most 10 times per second.
298
+ # No work to do from _refresh_content itself.
299
+ pass
300
+ elif role == "user":
301
+ assert isinstance(inner, Static)
302
+ inner.update(Text.from_markup(content))
303
+ elif role == "thinking":
304
+ assert isinstance(inner, Static)
305
+ if self._summary_text is not None:
306
+ inner.update(
307
+ Text.from_markup(
308
+ f"[dim {theme['thinking']}]✓ {self._summary_text}[/dim {theme['thinking']}]"
309
+ )
310
+ )
311
+ elif content:
312
+ # Debug mode: actual reasoning text streamed in — stop the
313
+ # spinner and render the accumulated content in muted grey.
314
+ if self._spin_timer is not None:
315
+ self._spin_timer.stop()
316
+ self._spin_timer = None
317
+ inner.update(
318
+ Text.from_markup(
319
+ f"[bold {theme['text_muted']}]Reasoning:[/bold {theme['text_muted']}]"
320
+ f"[{theme['text_muted']}] {escape(content)}[/{theme['text_muted']}]"
321
+ )
322
+ )
323
+ else:
324
+ spin = SPINNER[self._spin_idx]
325
+ inner.update(
326
+ Text.from_markup(
327
+ f"[bold {theme['thinking']}]{spin} {self._spin_label}…[/bold {theme['thinking']}]"
328
+ )
329
+ )
330
+ elif role == "question":
331
+ assert isinstance(inner, Static)
332
+ try:
333
+ inner.update(Text.from_markup(content))
334
+ except Exception:
335
+ inner.update(Text(content))
336
+
337
+ def _schedule_final_flush(self) -> None:
338
+ """Schedule one awaited Markdown rebuild via call_after_refresh (deduped)."""
339
+ if not self._flush_scheduled:
340
+ self._flush_scheduled = True
341
+ self.call_after_refresh(self._flush_agent)
342
+
343
+ def set_content(self, text: str) -> None:
344
+ self._content = text
345
+ if self._role == "agent":
346
+ self._stop_flush_timer()
347
+ self._dirty = True
348
+ self._schedule_final_flush()
349
+ else:
350
+ self._refresh_content()
351
+
352
+ def append(self, chunk: str) -> None:
353
+ self._content += chunk
354
+ if self._role == "agent":
355
+ # Buffer tokens; the flush timer does a single awaited rebuild at ~10 Hz.
356
+ self._dirty = True
357
+ if self._flush_timer is None:
358
+ self._flush_timer = self.set_interval(_BUBBLE_FLUSH_INTERVAL, self._flush_agent)
359
+ else:
360
+ self._refresh_content()
361
+
362
+ def _stop_flush_timer(self) -> None:
363
+ if self._flush_timer is not None:
364
+ self._flush_timer.stop()
365
+ self._flush_timer = None
366
+
367
+ async def _flush_agent(self) -> None:
368
+ """Flush buffered tokens to the Markdown widget (called at most 10×/sec).
369
+
370
+ Awaiting inner.update() serialises rebuilds so they can never race each
371
+ other.
372
+
373
+ If the bubble is not yet fully mounted (``_inner`` is None or
374
+ ``is_mounted`` is False), this returns early WITHOUT clearing
375
+ ``_dirty``. ``on_mount`` then schedules another flush once mount has
376
+ completed, guaranteeing the buffered content eventually renders.
377
+
378
+ Any exception is caught here rather than propagated: if Textual's
379
+ Markdown widget raises (e.g. during the async DOM operations inside
380
+ update()) the exception would otherwise travel through on_idle →
381
+ _handle_exception and exit the entire app. We log and swallow it so
382
+ the app stays alive; the worst case is stale rendered content.
383
+ """
384
+ try:
385
+ self._flush_scheduled = False
386
+ if not self._dirty:
387
+ return
388
+ inner = self._inner
389
+ if inner is None or not self.is_mounted:
390
+ # Leave _dirty=True so on_mount or the streaming timer retries.
391
+ return
392
+ assert isinstance(inner, TUIMarkdown)
393
+ # Snapshot content and clear _dirty BEFORE the await. If any
394
+ # append()/set_content()/finish() call arrives while
395
+ # inner.update() is suspended, it will set _dirty=True again and
396
+ # the next tick (or the final flush scheduled by finish()) will
397
+ # pick up the newer content. Clearing _dirty after the await
398
+ # would overwrite that flag and silently drop those tokens.
399
+ content = self._content
400
+ self._dirty = False
401
+ try:
402
+ await inner.update(content) # type: ignore[misc]
403
+ except Exception:
404
+ self._dirty = True # render failed; retry on the next tick
405
+ raise
406
+ except Exception:
407
+ logger.exception("_flush_agent failed — bubble content may be stale")
408
+
409
+ def finish(self) -> None:
410
+ if self._spin_timer is not None:
411
+ self._spin_timer.stop()
412
+ self._spin_timer = None
413
+ if self._role == "thinking" and self._summary_text is None:
414
+ self._summary_text = "Done thinking"
415
+ if self._role == "agent":
416
+ self._dirty = True
417
+ self._stop_flush_timer()
418
+ # Bypass the dedup guard: finish() is the authoritative "done" signal
419
+ # and must always schedule a render regardless of prior pending flushes.
420
+ self._flush_scheduled = False
421
+ self._schedule_final_flush()
422
+ else:
423
+ self._refresh_content()
424
+
425
+ def finish_with_summary(self, text: str) -> None:
426
+ """Stop the spinner and replace the bubble content with a static summary.
427
+
428
+ Designed for thinking bubbles: transforms the animated "Thinking…" into
429
+ a collapsed dim line like "✓ Thought for 12s".
430
+ """
431
+ if self._spin_timer is not None:
432
+ self._spin_timer.stop()
433
+ self._spin_timer = None
434
+ self._summary_text = text
435
+ inner = self._inner
436
+ if inner is None:
437
+ return
438
+ assert isinstance(inner, Static)
439
+ inner.update(
440
+ Text.from_markup(f"[dim {theme['thinking']}]✓ {text}[/dim {theme['thinking']}]")
441
+ )
442
+
443
+
444
+ class CompletionPopup(Static):
445
+ """File-path completion list shown above the input bar when typing @references."""
446
+
447
+ def show_matches(self, matches: list[str], selected: int) -> None:
448
+ lines = []
449
+ for i, m in enumerate(matches):
450
+ safe_match = escape(m)
451
+ if i == selected:
452
+ lines.append(f"[bold {theme['accent']}]▸ {safe_match}[/bold {theme['accent']}]")
453
+ else:
454
+ lines.append(f" [{theme['text_muted']}]{safe_match}[/{theme['text_muted']}]")
455
+ self.update(Text.from_markup("\n".join(lines)))
456
+ self.add_class("active")
457
+
458
+ def hide(self) -> None:
459
+ self.remove_class("active")
460
+ self.update("")
461
+
462
+
463
+ class _InputHistory:
464
+ """Keyboard-navigable history of submitted inputs.
465
+
466
+ Index convention: -1 = not navigating (showing live input or draft).
467
+ 0 = most-recent entry, 1 = second-most-recent, etc.
468
+ """
469
+
470
+ def __init__(self) -> None:
471
+ self._history: list[str] = []
472
+ self._index: int = -1
473
+ self._draft: str = ""
474
+
475
+ def push(self, text: str) -> None:
476
+ """Append *text* to history, ignoring consecutive duplicates."""
477
+ if text and (not self._history or self._history[-1] != text):
478
+ self._history.append(text)
479
+ self._index = -1
480
+ self._draft = ""
481
+
482
+ def navigate(self, direction: int, current_value: str) -> str | None:
483
+ """Return the entry to display after a navigation key press.
484
+
485
+ *direction* is -1 for UP (older) and +1 for DOWN (newer).
486
+ Returns the text to show, or None when the key has no effect.
487
+ """
488
+ if not self._history:
489
+ return None
490
+ if self._index == -1:
491
+ if direction == 1:
492
+ return None # DOWN with no active navigation — nothing to do
493
+ self._draft = current_value
494
+ self._index = 0
495
+ elif direction == -1:
496
+ if self._index >= len(self._history) - 1:
497
+ return None # Already at the oldest entry
498
+ self._index += 1
499
+ else:
500
+ self._index -= 1
501
+ if self._index < 0:
502
+ self._index = -1
503
+ return self._draft
504
+ return self._history[-(self._index + 1)]
505
+
506
+
507
+ class SmartInput(Input):
508
+ """Input widget that converts multi-line paste events into a typed Pasted message.
509
+
510
+ Single-line pastes pass through to the default Input handler unchanged.
511
+ Multi-line pastes (text containing a newline) are intercepted and posted
512
+ as ``SmartInput.Pasted`` so the controller can store them as a
513
+ ``PasteAttachment`` and display a compact pill in the UI.
514
+
515
+ Tab is intercepted here (at the focused-widget level) so it fires before
516
+ the Screen's default ``focus_next`` binding, enabling completion cycling.
517
+ Up/Down navigate the submission history when no completion popup is active.
518
+ """
519
+
520
+ BINDINGS = [
521
+ Binding("tab", "tab_complete_forward", show=False),
522
+ ]
523
+
524
+ class Pasted(Message):
525
+ """Posted when the user pastes multi-line text into the input."""
526
+
527
+ def __init__(self, text: str) -> None:
528
+ self._text = text
529
+ super().__init__()
530
+
531
+ class TabComplete(Message):
532
+ """Posted when Tab is pressed to trigger slash-command or @file completion."""
533
+
534
+ def __init__(self, direction: int = 1) -> None:
535
+ self._direction = direction
536
+ super().__init__()
537
+
538
+ def __init__(self, *args: object, **kwargs: object) -> None:
539
+ super().__init__(*args, **kwargs) # type: ignore[arg-type]
540
+ self._input_history = _InputHistory()
541
+
542
+ def push_history(self, text: str) -> None:
543
+ """Store a submitted text in history."""
544
+ self._input_history.push(text)
545
+
546
+ def navigate_history(self, direction: int) -> bool:
547
+ """Navigate history (direction=-1=UP older, +1=DOWN newer).
548
+
549
+ Returns True when navigation occurred and the input value was updated.
550
+ """
551
+ result = self._input_history.navigate(direction, self.value)
552
+ if result is None:
553
+ return False
554
+ self.value = result
555
+ self.cursor_position = len(result)
556
+ return True
557
+
558
+ def action_tab_complete_forward(self) -> None:
559
+ self.post_message(self.TabComplete(direction=1))
560
+
561
+ def _on_paste(self, event: events.Paste) -> None:
562
+ if "\n" in event.text:
563
+ self.post_message(self.Pasted(event.text))
564
+ else:
565
+ super()._on_paste(event)
566
+
567
+
568
+ class AttachmentBar(Static):
569
+ """Shows a paste-attachment pill above the input bar.
570
+
571
+ Becomes visible (via the ``active`` CSS class) when a paste attachment is
572
+ pending; hidden again on submission or Esc.
573
+ """
574
+
575
+ def show_pill(self, label: str) -> None:
576
+ safe = escape(label)
577
+ markup = (
578
+ f"[bold #58a6ff]📎 {safe}[/bold #58a6ff]"
579
+ f" [dim {theme['text_muted']}](Esc to discard)[/dim {theme['text_muted']}]"
580
+ )
581
+ self.update(Text.from_markup(markup))
582
+ self.add_class("active")
583
+
584
+ def hide(self) -> None:
585
+ self.remove_class("active")
586
+ self.update("")
587
+
588
+
589
+ class WorkspacePanel(Widget):
590
+ """Scrollable file listing panel shown below the input bar for /ls-workspace.
591
+
592
+ Up/Down to navigate, Escape or Ctrl+C to close.
593
+ """
594
+
595
+ BINDINGS = [
596
+ Binding("ctrl+c", "close_panel", show=False),
597
+ Binding("escape", "close_panel", show=False),
598
+ Binding("up", "move_up", show=False),
599
+ Binding("down", "move_down", show=False),
600
+ Binding("home", "move_home", show=False),
601
+ Binding("end", "move_end", show=False),
602
+ Binding("pageup", "move_page_up", show=False),
603
+ Binding("pagedown", "move_page_down", show=False),
604
+ ]
605
+
606
+ can_focus = True
607
+ PAGE_SIZE = 12
608
+
609
+ def __init__(self, *args: object, **kwargs: object) -> None:
610
+ super().__init__(*args, **kwargs) # type: ignore[arg-type]
611
+ self._files: list[str] = []
612
+ self._selected: int = 0
613
+ self._offset: int = 0
614
+
615
+ def compose(self) -> ComposeResult:
616
+ yield Static(id="workspace-panel-content")
617
+
618
+ def show_files(self, files: list[str]) -> None:
619
+ self._files = files
620
+ self._selected = 0
621
+ self._offset = 0
622
+ self.add_class("active")
623
+ self.focus()
624
+ self._update_content()
625
+
626
+ def _update_content(self) -> None:
627
+ files = self._files
628
+ content_widget = self.query_one("#workspace-panel-content", Static)
629
+ if not files:
630
+ content_widget.update(
631
+ Text.from_markup(
632
+ f"[dim {theme['text_secondary']}]No files in active workspace.[/dim {theme['text_secondary']}]"
633
+ )
634
+ )
635
+ return
636
+
637
+ count = len(files)
638
+ hint = (
639
+ f"[dim {theme['text_secondary']}]"
640
+ f"↑↓ navigate PgUp/PgDn page Home/End jump Esc close "
641
+ f"{count} file{'s' if count != 1 else ''}"
642
+ f"[/dim {theme['text_secondary']}]"
643
+ )
644
+ lines = [hint]
645
+ visible = files[self._offset : self._offset + self.PAGE_SIZE]
646
+ for i, name in enumerate(visible):
647
+ abs_idx = self._offset + i
648
+ if abs_idx == self._selected:
649
+ lines.append(f"[bold {theme['accent']}]▸ {name}[/bold {theme['accent']}]")
650
+ else:
651
+ lines.append(f" [{theme['text_secondary']}]{name}[/{theme['text_secondary']}]")
652
+ if count > self.PAGE_SIZE:
653
+ lo = self._offset + 1
654
+ hi = min(self._offset + self.PAGE_SIZE, count)
655
+ lines.append(
656
+ f"[dim {theme['text_secondary']}] {lo}–{hi} of {count}[/dim {theme['text_secondary']}]"
657
+ )
658
+ content_widget.update(Text.from_markup("\n".join(lines)))
659
+
660
+ def action_move_up(self) -> None:
661
+ if self._selected > 0:
662
+ self._selected -= 1
663
+ if self._selected < self._offset:
664
+ self._offset = self._selected
665
+ self._update_content()
666
+
667
+ def action_move_down(self) -> None:
668
+ if self._selected < len(self._files) - 1:
669
+ self._selected += 1
670
+ if self._selected >= self._offset + self.PAGE_SIZE:
671
+ self._offset = self._selected - self.PAGE_SIZE + 1
672
+ self._update_content()
673
+
674
+ def action_move_home(self) -> None:
675
+ if not self._files:
676
+ return
677
+ self._selected = 0
678
+ self._offset = 0
679
+ self._update_content()
680
+
681
+ def action_move_end(self) -> None:
682
+ if not self._files:
683
+ return
684
+ self._selected = len(self._files) - 1
685
+ self._offset = max(0, len(self._files) - self.PAGE_SIZE)
686
+ self._update_content()
687
+
688
+ def action_move_page_up(self) -> None:
689
+ if not self._files or self._selected == 0:
690
+ return
691
+ self._selected = max(0, self._selected - self.PAGE_SIZE)
692
+ if self._selected < self._offset:
693
+ self._offset = self._selected
694
+ self._update_content()
695
+
696
+ def action_move_page_down(self) -> None:
697
+ if not self._files or self._selected >= len(self._files) - 1:
698
+ return
699
+ self._selected = min(len(self._files) - 1, self._selected + self.PAGE_SIZE)
700
+ if self._selected >= self._offset + self.PAGE_SIZE:
701
+ self._offset = self._selected - self.PAGE_SIZE + 1
702
+ self._update_content()
703
+
704
+ def action_close_panel(self) -> None:
705
+ self.remove_class("active")
706
+ self._files = []
707
+ try:
708
+ self.app.query_one("#user-input", Input).focus()
709
+ except Exception:
710
+ pass
711
+
712
+
713
+ class ThinkingBlock(Static):
714
+ """Ephemeral 'Thinking...' indicator shown while the LLM is processing.
715
+
716
+ Displays a cycling dots animation (Thinking → Thinking. → Thinking.. →
717
+ Thinking...) in a muted grey so it doesn't dominate the screen. Call
718
+ ``dismiss()`` to remove the block from the DOM.
719
+ """
720
+
721
+ DEFAULT_CSS = """
722
+ ThinkingBlock {
723
+ height: auto;
724
+ padding: 0 1;
725
+ margin-bottom: 1;
726
+ }
727
+ """
728
+
729
+ _DOTS = ["", ".", "..", "..."]
730
+
731
+ def __init__(self) -> None:
732
+ super().__init__("")
733
+ self._dot_idx = 0
734
+ self._spin_timer: Timer | None = None
735
+
736
+ def on_mount(self) -> None:
737
+ self._spin_timer = self.set_interval(0.35, self._tick)
738
+ self._update_display()
739
+
740
+ def _tick(self) -> None:
741
+ self._dot_idx = (self._dot_idx + 1) % len(self._DOTS)
742
+ self._update_display()
743
+
744
+ def _update_display(self) -> None:
745
+ dots = self._DOTS[self._dot_idx]
746
+ self.update(
747
+ Text.from_markup(
748
+ f"[dim {theme['text_muted']}]💭 Thinking{dots}[/dim {theme['text_muted']}]"
749
+ )
750
+ )
751
+
752
+ def dismiss(self) -> None:
753
+ self.remove()
754
+
755
+ def finish(self, summary: str) -> None:
756
+ if self._spin_timer is not None:
757
+ self._spin_timer.stop()
758
+ self._spin_timer = None
759
+ self.update(
760
+ Text.from_markup(f"[dim {theme['text_muted']}]{summary}[/dim {theme['text_muted']}]")
761
+ )
762
+
763
+ def on_unmount(self) -> None:
764
+ if self._spin_timer is not None:
765
+ self._spin_timer.stop()
766
+ self._spin_timer = None
767
+
768
+
769
+ class ChatPane(Widget):
770
+ """Left pane: scrollable message history + input bar."""
771
+
772
+ def compose(self) -> ComposeResult:
773
+ yield ScrollableContainer(id="messages")
774
+ with Vertical(id="input-bar"):
775
+ yield CompletionPopup(id="completion-popup")
776
+ yield AttachmentBar(id="attachment-bar")
777
+ yield SmartInput(
778
+ placeholder="Ask anything… (/ for commands, @ to attach files)",
779
+ id="user-input",
780
+ highlighter=CommandHighlighter(),
781
+ )
782
+ yield WorkspacePanel(id="workspace-panel")
783
+
784
+ def add_message(self, role: str, content: str = "") -> MessageBubble:
785
+ bubble = MessageBubble(role, content)
786
+ self.query_one("#messages", ScrollableContainer).mount(bubble)
787
+ return bubble
788
+
789
+ def add_divider(self) -> None:
790
+ divider = Static(Rule(style=theme["separator"]), classes="msg-divider")
791
+ self.query_one("#messages", ScrollableContainer).mount(divider)
792
+
793
+ def add_turn_status_bar(self) -> "TurnStatusBar":
794
+ for existing in self.query(TurnStatusBar):
795
+ existing.remove()
796
+ timer = TurnStatusBar()
797
+ self.mount(timer, after=self.query_one("#input-bar"))
798
+ return timer
799
+
800
+ def add_thinking_block(self) -> "ThinkingBlock":
801
+ block = ThinkingBlock()
802
+ self.query_one("#messages", ScrollableContainer).mount(block)
803
+ return block
804
+
805
+ def add_ephemeral_block(self, communication: str, label: str, summary: str) -> "ToolCallBlock":
806
+ widget = ToolCallBlock(communication, label, summary)
807
+ self.query_one("#messages", ScrollableContainer).mount(widget)
808
+ return widget
809
+
810
+ def add_worker_block(self, communication: str, worker_summaries: list[str]) -> "ToolCallBlock":
811
+ widget = ToolCallBlock(communication, "", "", worker_summaries=worker_summaries)
812
+ self.query_one("#messages", ScrollableContainer).mount(widget)
813
+ return widget
814
+
815
+ def show_workspace_panel(self, files: list[str]) -> None:
816
+ self.query_one("#workspace-panel", WorkspacePanel).show_files(files)
817
+
818
+ def show_attachment(self, label: str) -> None:
819
+ self.query_one("#attachment-bar", AttachmentBar).show_pill(label)
820
+
821
+ def hide_attachment(self) -> None:
822
+ self.query_one("#attachment-bar", AttachmentBar).hide()
823
+
824
+ def clear_messages(self) -> None:
825
+ self.query_one("#messages", ScrollableContainer).remove_children()
826
+
827
+
828
+ class ToolCallBlock(Static):
829
+ """Ephemeral status block: optional communication line + tool status line(s).
830
+
831
+ Shows blue while the tool is running; call ``set_done()`` to turn green.
832
+ Call ``dismiss()`` to remove from the DOM entirely.
833
+ For ``spawn_workers``, pass ``worker_summaries`` to get one status line per worker.
834
+ Worker rows can be individually marked done (green ✓) or error (red ✗).
835
+ """
836
+
837
+ DEFAULT_CSS = """
838
+ ToolCallBlock {
839
+ height: auto;
840
+ padding: 0;
841
+ margin-bottom: 1;
842
+ }
843
+ """
844
+
845
+ def __init__(
846
+ self,
847
+ communication: str,
848
+ label: str,
849
+ summary: str,
850
+ worker_summaries: list[str] | None = None,
851
+ ) -> None:
852
+ super().__init__("")
853
+ self._communication: str | None = communication
854
+ self._label = label
855
+ self._summary = summary
856
+ self._worker_summaries = worker_summaries or []
857
+ # Per-worker three-state status: "running" | "done" | "error"
858
+ self._worker_statuses: list[str] = ["running"] * len(self._worker_summaries)
859
+ # Current tool name / activity string shown inline for running workers.
860
+ self._worker_activities: list[str] = [""] * len(self._worker_summaries)
861
+ self._done = False
862
+ self._error = False
863
+ self._spin_idx = 0
864
+ self._spin_timer: Timer | None = None
865
+
866
+ def on_mount(self) -> None:
867
+ if not self._done:
868
+ self._spin_timer = self.set_interval(SPINNER_INTERVAL, self._tick)
869
+ self._refresh()
870
+
871
+ def _tick(self) -> None:
872
+ self._spin_idx = (self._spin_idx + 1) % len(SPINNER)
873
+ self._refresh()
874
+
875
+ def _status_markup(self, text: str, done: bool | None = None) -> str:
876
+ safe_text = escape(text)
877
+ if self._error:
878
+ return f"[bold {theme['error']}]✗ {safe_text}[/bold {theme['error']}]"
879
+ is_done = self._done if done is None else done
880
+ if is_done:
881
+ return f"[bold {theme['tool_done']}]{safe_text}[/bold {theme['tool_done']}]"
882
+ spin = SPINNER[self._spin_idx]
883
+ return f"[bold {theme['tool_running']}]{spin} {safe_text}[/bold {theme['tool_running']}]"
884
+
885
+ def _worker_status_markup(self, text: str, status: str, prefix: str = "") -> str:
886
+ """Return markup for a single worker row based on its status.
887
+
888
+ ``prefix`` is placed inside the bold/colour span so it inherits the
889
+ row's status colour (running/done/error)."""
890
+ safe_text = escape(text)
891
+ if status == "error":
892
+ return f"[bold {theme['error']}]{prefix}✗ {safe_text}[/bold {theme['error']}]"
893
+ if status == "done":
894
+ return f"[bold {theme['tool_done']}]{prefix}{safe_text}[/bold {theme['tool_done']}]"
895
+ spin = SPINNER[self._spin_idx]
896
+ return f"[bold {theme['tool_running']}]{prefix}{spin} {safe_text}[/bold {theme['tool_running']}]"
897
+
898
+ def _refresh(self) -> None:
899
+ lines: list[str] = []
900
+ if self._worker_summaries:
901
+ all_terminal = all(s != "running" for s in self._worker_statuses)
902
+ if self._communication:
903
+ lines.append(escape(self._communication))
904
+ lines.append("")
905
+ lines.append(self._status_markup("⚡ Parallelizing", done=self._done or all_terminal))
906
+ for i, s in enumerate(self._worker_summaries):
907
+ if self._done:
908
+ # Force-done: keep terminal rows as-is; promote any still-running row to
909
+ # "error" if the block itself errored, otherwise to "done".
910
+ cur = self._worker_statuses[i]
911
+ if cur == "running":
912
+ st = "error" if self._error else "done"
913
+ else:
914
+ st = cur
915
+ else:
916
+ st = self._worker_statuses[i]
917
+ activity = self._worker_activities[i] if i < len(self._worker_activities) else ""
918
+ label = f"Worker {i + 1}: {activity if activity and st == 'running' else s}"
919
+ display = self._worker_status_markup(label, st, prefix=" └─ ")
920
+ lines.append(display)
921
+ else:
922
+ display = self._summary if self._summary else self._label
923
+ if self._communication:
924
+ lines.append(escape(self._communication))
925
+ lines.append("") # blank line so the gap matches the inter-block margin
926
+ lines.append(self._status_markup(display))
927
+ self.update(Text.from_markup("\n".join(lines)))
928
+
929
+ def _stop_spinner(self) -> None:
930
+ """Stop the spinner timer (called when all workers reach a terminal state)."""
931
+ if self._spin_timer is not None:
932
+ self._spin_timer.stop()
933
+ self._spin_timer = None
934
+
935
+ def mark_worker_done(self, idx: int) -> None:
936
+ if 0 <= idx < len(self._worker_statuses):
937
+ self._worker_statuses[idx] = "done"
938
+ if idx < len(self._worker_activities):
939
+ self._worker_activities[idx] = ""
940
+ if all(s != "running" for s in self._worker_statuses):
941
+ self._stop_spinner()
942
+ self._done = True
943
+ self._refresh()
944
+
945
+ def mark_worker_error(self, idx: int) -> None:
946
+ if 0 <= idx < len(self._worker_statuses):
947
+ self._worker_statuses[idx] = "error"
948
+ if idx < len(self._worker_activities):
949
+ self._worker_activities[idx] = ""
950
+ if all(s != "running" for s in self._worker_statuses):
951
+ self._stop_spinner()
952
+ self._done = True
953
+ self._refresh()
954
+
955
+ def update_worker_activity(self, idx: int, activity: str) -> None:
956
+ """Update the inline activity label for a running worker row."""
957
+ if 0 <= idx < len(self._worker_activities) and self._worker_statuses[idx] == "running":
958
+ if self._worker_activities[idx] == activity:
959
+ return
960
+ self._worker_activities[idx] = activity
961
+ self._refresh()
962
+
963
+ def set_communication(self, text: str | None) -> None:
964
+ """Update the communication line while the tool's args are still streaming."""
965
+ self._communication = text
966
+ self._refresh()
967
+
968
+ def upgrade(self, label: str, summary: str) -> None:
969
+ """Replace the generic pending label with the real tool label/summary once tool_call fires."""
970
+ self._label = label
971
+ self._summary = summary
972
+ self._refresh()
973
+
974
+ def set_done(self) -> None:
975
+ self._done = True
976
+ self._stop_spinner()
977
+ self._refresh()
978
+
979
+ def set_error(self) -> None:
980
+ self._error = True
981
+ self._done = True
982
+ self._stop_spinner()
983
+ self._refresh()
984
+
985
+ def is_running(self) -> bool: # type: ignore[override]
986
+ return not self._done
987
+
988
+ def on_unmount(self) -> None:
989
+ self._stop_spinner()
990
+
991
+ def dismiss(self) -> None:
992
+ self.remove()
993
+
994
+
995
+ # Register Textual widget implementations as virtual subclasses of their ABCs.
996
+ # Direct inheritance is not possible due to a metaclass conflict between
997
+ # Textual's _MessagePumpMeta and ABCMeta.
998
+ MessageHandle.register(MessageBubble)
999
+ EphemeralHandle.register(ToolCallBlock)
1000
+ TurnStatusHandle.register(TurnStatusBar)
1001
+ ThinkingHandle.register(ThinkingBlock)