open-data-sci 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- open_data_sci-0.1.0.dist-info/METADATA +629 -0
- open_data_sci-0.1.0.dist-info/RECORD +85 -0
- open_data_sci-0.1.0.dist-info/WHEEL +4 -0
- open_data_sci-0.1.0.dist-info/entry_points.txt +2 -0
- open_data_sci-0.1.0.dist-info/licenses/LICENSE +201 -0
- opendatasci/__init__.py +47 -0
- opendatasci/_tui/__init__.py +1 -0
- opendatasci/_tui/adapter.py +102 -0
- opendatasci/_tui/app.py +429 -0
- opendatasci/_tui/commands.py +95 -0
- opendatasci/_tui/completion.py +139 -0
- opendatasci/_tui/controller.py +644 -0
- opendatasci/_tui/file_refs.py +153 -0
- opendatasci/_tui/models.py +4 -0
- opendatasci/_tui/presenter.py +259 -0
- opendatasci/_tui/service.py +78 -0
- opendatasci/_tui/session.py +53 -0
- opendatasci/_tui/styles.tcss +248 -0
- opendatasci/_tui/styles_visible.tcss +245 -0
- opendatasci/_tui/theme.py +113 -0
- opendatasci/_tui/tools_display.py +86 -0
- opendatasci/_tui/widgets.py +1001 -0
- opendatasci/_utils/__init__.py +0 -0
- opendatasci/_utils/async_utils.py +11 -0
- opendatasci/_utils/data_formats.py +135 -0
- opendatasci/_utils/hash_utils.py +52 -0
- opendatasci/_utils/langchain_utils.py +155 -0
- opendatasci/_utils/streaming_utils.py +23 -0
- opendatasci/agents/__init__.py +12 -0
- opendatasci/agents/agents.py +515 -0
- opendatasci/agents/agents_factory.py +71 -0
- opendatasci/agents/chat_memory.py +397 -0
- opendatasci/agents/graphs.py +84 -0
- opendatasci/agents/nodes.py +74 -0
- opendatasci/agents/states.py +36 -0
- opendatasci/agents/turn_memory.py +124 -0
- opendatasci/configs.py +275 -0
- opendatasci/context/__init__.py +7 -0
- opendatasci/context/base.py +56 -0
- opendatasci/context/local.py +236 -0
- opendatasci/models/__init__.py +7 -0
- opendatasci/models/anthropic.py +40 -0
- opendatasci/models/aws.py +86 -0
- opendatasci/models/factory.py +179 -0
- opendatasci/models/google.py +79 -0
- opendatasci/models/local.py +79 -0
- opendatasci/models/microsoft.py +62 -0
- opendatasci/models/openai.py +49 -0
- opendatasci/models/providers.py +12 -0
- opendatasci/prompts/__init__.py +5 -0
- opendatasci/prompts/builders.py +85 -0
- opendatasci/prompts/caching.py +42 -0
- opendatasci/prompts/message_templates.py +7 -0
- opendatasci/prompts/prompt_templates.py +227 -0
- opendatasci/resources/skills/competitive_data_science.md +241 -0
- opendatasci/resources/skills/data_science.md +55 -0
- opendatasci/resources/skills/data_science_education.md +42 -0
- opendatasci/resources/skills/deep_learning.md +205 -0
- opendatasci/resources/skills/machine_learning.md +68 -0
- opendatasci/resources/skills/quantitative_analysis.md +45 -0
- opendatasci/sandbox/__init__.py +14 -0
- opendatasci/sandbox/_runner.py +114 -0
- opendatasci/sandbox/base.py +170 -0
- opendatasci/sandbox/srt.py +490 -0
- opendatasci/skills/__init__.py +9 -0
- opendatasci/skills/base.py +28 -0
- opendatasci/skills/local.py +131 -0
- opendatasci/streaming/__init__.py +37 -0
- opendatasci/streaming/events.py +159 -0
- opendatasci/streaming/processors.py +387 -0
- opendatasci/tools/__init__.py +58 -0
- opendatasci/tools/coding.py +261 -0
- opendatasci/tools/critic.py +136 -0
- opendatasci/tools/dataset_info.py +391 -0
- opendatasci/tools/factory.py +172 -0
- opendatasci/tools/mcp.py +179 -0
- opendatasci/tools/planning.py +88 -0
- opendatasci/tools/skills.py +90 -0
- opendatasci/tools/user_interaction.py +54 -0
- opendatasci/tools/web.py +236 -0
- opendatasci/tools/workers.py +237 -0
- opendatasci/tools/workspace.py +55 -0
- opendatasci/workspace/__init__.py +9 -0
- opendatasci/workspace/base.py +20 -0
- opendatasci/workspace/local.py +25 -0
|
@@ -0,0 +1,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)
|