omega-code 0.4.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.
- omega/__init__.py +0 -0
- omega/__main__.py +589 -0
- omega/artifacts.py +151 -0
- omega/checkpoint.py +246 -0
- omega/compact.py +106 -0
- omega/config.py +285 -0
- omega/eval/__init__.py +3 -0
- omega/eval/cli.py +127 -0
- omega/eval/examples/plan-version-flag.yaml +11 -0
- omega/eval/examples/relative-age-negative-delta.yaml +14 -0
- omega/eval/examples/version-flag.yaml +10 -0
- omega/eval/manifest.py +129 -0
- omega/eval/prices.py +29 -0
- omega/eval/report.py +135 -0
- omega/eval/runner.py +199 -0
- omega/eval/tasks.py +97 -0
- omega/events.py +145 -0
- omega/export.py +80 -0
- omega/gitlog.py +229 -0
- omega/hooks.py +63 -0
- omega/instructions.py +103 -0
- omega/integrations.py +284 -0
- omega/keys.py +173 -0
- omega/llm.py +442 -0
- omega/loop.py +510 -0
- omega/mcp.py +490 -0
- omega/memory/__init__.py +5 -0
- omega/memory/consolidate.py +103 -0
- omega/memory/curate.py +69 -0
- omega/memory/store.py +321 -0
- omega/memory/tools.py +175 -0
- omega/migrate.py +40 -0
- omega/onboarding.py +242 -0
- omega/permissions.py +137 -0
- omega/secrets.py +173 -0
- omega/server/__init__.py +7 -0
- omega/server/__main__.py +18 -0
- omega/server/app.py +71 -0
- omega/server/auth.py +73 -0
- omega/server/manager.py +287 -0
- omega/server/models.py +123 -0
- omega/server/tasks_api.py +311 -0
- omega/server/terminals.py +245 -0
- omega/server/worker.py +186 -0
- omega/session.py +209 -0
- omega/setup.html +281 -0
- omega/setup_server.py +452 -0
- omega/skills.py +158 -0
- omega/subagent.py +98 -0
- omega/tasks.py +195 -0
- omega/tools.py +590 -0
- omega/trace.py +156 -0
- omega/trajectory.py +146 -0
- omega/ui/__init__.py +0 -0
- omega/ui/composer.py +140 -0
- omega/ui/format.py +708 -0
- omega/ui/plain.py +141 -0
- omega/ui/tui/__init__.py +9 -0
- omega/ui/tui/app.py +958 -0
- omega/ui/tui/history.py +50 -0
- omega/ui/tui/modals.py +292 -0
- omega/ui/tui/onboarding.py +367 -0
- omega/ui/tui/prefs.py +25 -0
- omega/ui/tui/sidebar.py +510 -0
- omega/ui/tui/status.py +115 -0
- omega/ui/tui/theme.py +91 -0
- omega/ui/tui/transcript.py +783 -0
- omega/verify.py +133 -0
- omega_code-0.4.0.dist-info/METADATA +479 -0
- omega_code-0.4.0.dist-info/RECORD +73 -0
- omega_code-0.4.0.dist-info/WHEEL +4 -0
- omega_code-0.4.0.dist-info/entry_points.txt +2 -0
- omega_code-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
"""The scrolling conversation pane. Renders the dim one-liners via
|
|
2
|
+
`ui/format.py`, which both front ends share so they cannot drift on wording
|
|
3
|
+
(plain.py's tests print through a live `rich.Console` and are left untouched).
|
|
4
|
+
|
|
5
|
+
Layout follows the reference terminal-agent look: each top-level block (user
|
|
6
|
+
prompt, assistant reply, a burst of tool calls, a subagent) is separated from
|
|
7
|
+
its neighbors by one blank line; a tool's outcome (or a subagent's nested
|
|
8
|
+
calls) renders as a dim `└ ...` sub-line beneath its block instead of a second
|
|
9
|
+
top-level line; and a single live status line docked to the bottom of the
|
|
10
|
+
pane tracks the running turn (phase, elapsed time, tokens, thinking time)
|
|
11
|
+
instead of per-row spinners."""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import textwrap
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from rich.markdown import Markdown
|
|
21
|
+
from textual.binding import Binding
|
|
22
|
+
from textual.containers import VerticalScroll
|
|
23
|
+
from textual.events import Click, Resize
|
|
24
|
+
from textual.timer import Timer
|
|
25
|
+
from textual.widgets import Static
|
|
26
|
+
|
|
27
|
+
from ... import events
|
|
28
|
+
from .. import format
|
|
29
|
+
from . import theme
|
|
30
|
+
from .status import SPINNER_FRAMES
|
|
31
|
+
|
|
32
|
+
_GROUP_CAP = 3
|
|
33
|
+
# The result column: `⎿` hangs off the call row, and every following line
|
|
34
|
+
# aligns to the character after it.
|
|
35
|
+
_RESULT_MARK = "⎿"
|
|
36
|
+
_RESULT_INDENT = " " * 5
|
|
37
|
+
# Rail lines an error shows before it asks to be expanded -- enough for the
|
|
38
|
+
# exception type and the path or assertion that caused it, which is what
|
|
39
|
+
# decides the next action.
|
|
40
|
+
_ERROR_BODY_LINES = 3
|
|
41
|
+
|
|
42
|
+
# The streaming read-cursor: a block that sits on the newest character so the
|
|
43
|
+
# eye can find where the text currently is, instead of re-scanning the whole
|
|
44
|
+
# paragraph after every repaint.
|
|
45
|
+
#
|
|
46
|
+
# It pulses on a heartbeat envelope rather than an even blink -- two quick
|
|
47
|
+
# beats and a rest, at roughly 55bpm. An even blink reads as a warning
|
|
48
|
+
# indicator and competes for attention; a heartbeat reads as "alive" and the
|
|
49
|
+
# eye stops tracking it consciously within a second or two, which is exactly
|
|
50
|
+
# what a position marker should do.
|
|
51
|
+
# Brightness, not hue. A prose block renders through a rich `Table.grid`
|
|
52
|
+
# (the bullet gutter), and rich resolves markup on its own -- it has never
|
|
53
|
+
# heard of Textual's `$accent`, so a themed tag here raises `MarkupError` and
|
|
54
|
+
# takes the repaint down with it. `bold`/`dim` are rich's own, they inherit
|
|
55
|
+
# whatever foreground the terminal is already using, and a pulse in
|
|
56
|
+
# brightness is what reads as a heartbeat anyway.
|
|
57
|
+
_CURSOR = "▊"
|
|
58
|
+
_CURSOR_BEAT = ("bold", "bold", "dim", "bold", "bold",
|
|
59
|
+
"dim", "dim", "dim", "dim", "dim")
|
|
60
|
+
_CURSOR_INTERVAL = 0.11
|
|
61
|
+
# How long the cursor lingers on the last character after the stream stops.
|
|
62
|
+
# The final delta and the turn ending are one instant apart; without a pause
|
|
63
|
+
# the marker vanishes exactly when the reader looks for where the text
|
|
64
|
+
# stopped.
|
|
65
|
+
_CURSOR_LINGER = 0.9
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _flatten_truncate(text: str, limit: int) -> str:
|
|
69
|
+
flat = " ".join(text.split())
|
|
70
|
+
return flat if len(flat) <= limit else flat[: limit - 1] + "…"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class _Group:
|
|
75
|
+
is_sub: bool = False
|
|
76
|
+
shown: int = 0
|
|
77
|
+
total: int = 0
|
|
78
|
+
hidden: list[events.ToolStart] = field(default_factory=list)
|
|
79
|
+
more: Static | None = None
|
|
80
|
+
expanded: bool = False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _MoreLine(Static, can_focus=True):
|
|
84
|
+
"""A collapsed-group continuation line. Real focusable row: enter or a
|
|
85
|
+
click expands it, same as any other interactive control."""
|
|
86
|
+
|
|
87
|
+
DEFAULT_CSS = """
|
|
88
|
+
_MoreLine { height: 1; }
|
|
89
|
+
_MoreLine:focus { text-style: underline; }
|
|
90
|
+
"""
|
|
91
|
+
BINDINGS = [Binding("enter", "activate", show=False)]
|
|
92
|
+
|
|
93
|
+
def __init__(self, markup: str, on_click: Callable[[], None]) -> None:
|
|
94
|
+
super().__init__(markup)
|
|
95
|
+
self._click_cb = on_click
|
|
96
|
+
|
|
97
|
+
def on_click(self, event: Click) -> None:
|
|
98
|
+
self._click_cb()
|
|
99
|
+
|
|
100
|
+
def action_activate(self) -> None:
|
|
101
|
+
self._click_cb()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _ToolBlock(Static, can_focus=True):
|
|
105
|
+
"""One tool call, whole: its call row, a few lines of the tool's REAL
|
|
106
|
+
output on a `│` rail, and the `└ …` outcome footer.
|
|
107
|
+
|
|
108
|
+
One widget instead of the three rows this used to mount, for two
|
|
109
|
+
reasons. Correctness: a call's outcome now lands under its own row, where
|
|
110
|
+
three independently mounted rows let three parallel tools pile all three
|
|
111
|
+
outcomes at the bottom, attached to whichever row happened to finish
|
|
112
|
+
last. And affordance: the block owns its own collapsed/expanded state, so
|
|
113
|
+
enter or a click swaps the preview for a much longer excerpt in place --
|
|
114
|
+
the same one-key expansion `_MoreLine` gives a collapsed group."""
|
|
115
|
+
|
|
116
|
+
DEFAULT_CSS = """
|
|
117
|
+
_ToolBlock { height: auto; }
|
|
118
|
+
_ToolBlock:focus { background: $boost; }
|
|
119
|
+
"""
|
|
120
|
+
BINDINGS = [Binding("enter", "expand", show=False)]
|
|
121
|
+
|
|
122
|
+
def __init__(self, start: events.ToolStart, *, show_suffix: bool,
|
|
123
|
+
width_of: Callable[[], int | None],
|
|
124
|
+
body_width_of: Callable[[], int | None]) -> None:
|
|
125
|
+
super().__init__()
|
|
126
|
+
self._start = start
|
|
127
|
+
self._show_suffix = show_suffix
|
|
128
|
+
self._width_of = width_of
|
|
129
|
+
self._body_width_of = body_width_of
|
|
130
|
+
self._end: events.ToolEnd | None = None
|
|
131
|
+
self._repeat = 1
|
|
132
|
+
self._expanded = False
|
|
133
|
+
self.update(self._markup())
|
|
134
|
+
|
|
135
|
+
# ---- state changes -----------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def note_end(self, ev: events.ToolEnd) -> None:
|
|
138
|
+
self._end = ev
|
|
139
|
+
self.update(self._markup())
|
|
140
|
+
|
|
141
|
+
def note_repeat(self, count: int) -> None:
|
|
142
|
+
self._repeat = count
|
|
143
|
+
self.update(self._markup())
|
|
144
|
+
|
|
145
|
+
def rerender(self) -> None:
|
|
146
|
+
"""Rebuild against the pane's CURRENT width -- without this a row's
|
|
147
|
+
truncation stays frozen at whatever width was current when it was
|
|
148
|
+
first mounted, so a resize leaves rows wrapping or stuck short."""
|
|
149
|
+
self.update(self._markup())
|
|
150
|
+
|
|
151
|
+
def on_click(self, event: Click) -> None:
|
|
152
|
+
self.action_expand()
|
|
153
|
+
|
|
154
|
+
def action_expand(self) -> None:
|
|
155
|
+
if not self._expandable():
|
|
156
|
+
return
|
|
157
|
+
self._expanded = not self._expanded
|
|
158
|
+
self.update(self._markup())
|
|
159
|
+
|
|
160
|
+
# ---- rendering ---------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def _failed(self) -> bool:
|
|
163
|
+
return self._end is not None and self._end.outcome.startswith("→ error")
|
|
164
|
+
|
|
165
|
+
def _error_text(self) -> str:
|
|
166
|
+
outcome = self._end.outcome if self._end is not None else ""
|
|
167
|
+
body = outcome[2:] if outcome.startswith("→ ") else outcome
|
|
168
|
+
return body[len("error:"):].strip() if body.startswith("error:") else body
|
|
169
|
+
|
|
170
|
+
def _body(self) -> tuple[list[str], int]:
|
|
171
|
+
"""The block's evidence lines and how many more the full result holds.
|
|
172
|
+
|
|
173
|
+
Success and failure share one shape on purpose: the result column
|
|
174
|
+
always carries what the tool actually produced. An error used to be
|
|
175
|
+
squeezed into a one-line footer instead, where it wrapped flush
|
|
176
|
+
against the left margin and broke the block's own indentation the
|
|
177
|
+
moment it ran past one line."""
|
|
178
|
+
if self._end is None:
|
|
179
|
+
return [], 0
|
|
180
|
+
width = self._body_width_of() or 60
|
|
181
|
+
if self._failed():
|
|
182
|
+
# Wrapped rather than truncated: a stack trace or a provider's
|
|
183
|
+
# error body is the whole point of the row, and the reason to
|
|
184
|
+
# read it is usually past the first 80 columns.
|
|
185
|
+
limit = format.PREVIEW_EXPANDED if self._expanded else _ERROR_BODY_LINES
|
|
186
|
+
wrapped = textwrap.wrap(self._error_text(), width) or [""]
|
|
187
|
+
return wrapped[:limit], max(0, len(wrapped) - limit)
|
|
188
|
+
# `None` lets each tool's own budget in `PREVIEW_BUDGET` decide.
|
|
189
|
+
budget = format.PREVIEW_EXPANDED if self._expanded else None
|
|
190
|
+
shown = format.preview_lines(self._end.name, self._end.result_preview,
|
|
191
|
+
limit=budget, width=width)
|
|
192
|
+
hidden = format.preview_hidden(self._end.name, self._end.result_preview, len(shown))
|
|
193
|
+
return shown, hidden
|
|
194
|
+
|
|
195
|
+
def _expandable(self) -> bool:
|
|
196
|
+
if self._end is None:
|
|
197
|
+
return False
|
|
198
|
+
return self._expanded or self._body()[1] > 0
|
|
199
|
+
|
|
200
|
+
def _line_style(self, line: str) -> str:
|
|
201
|
+
if self._failed():
|
|
202
|
+
return "red"
|
|
203
|
+
if self._end is not None and self._end.name in format.DIFF_RESULT_TOOLS:
|
|
204
|
+
return format.diff_style(line)
|
|
205
|
+
return "dim"
|
|
206
|
+
|
|
207
|
+
def _result_rows(self) -> list[tuple[str, str]]:
|
|
208
|
+
"""`(style, text)` for everything below the call row: the verdict
|
|
209
|
+
first, then the tool's own output, then the elision line."""
|
|
210
|
+
if self._end is None:
|
|
211
|
+
return []
|
|
212
|
+
rows: list[tuple[str, str]] = []
|
|
213
|
+
if self._end.outcome and not self._failed():
|
|
214
|
+
# The verdict LEADS. A summary read after the evidence it
|
|
215
|
+
# summarises arrives too late to be doing any work. A failure has
|
|
216
|
+
# no separate verdict -- its message IS the body, below.
|
|
217
|
+
rows.append(("dim", self._end.outcome.removeprefix("→ ")))
|
|
218
|
+
body, hidden = self._body()
|
|
219
|
+
rows += [(self._line_style(line), line) for line in body]
|
|
220
|
+
if self._expanded:
|
|
221
|
+
rows.append(("dim", "▾ enter to collapse"))
|
|
222
|
+
elif hidden:
|
|
223
|
+
# With nothing on show, the count above IS the total -- repeating
|
|
224
|
+
# it as "+60 lines" reads like a second, different number.
|
|
225
|
+
more = f"+{hidden} line" if hidden == 1 else f"+{hidden} lines"
|
|
226
|
+
rows.append(("dim", f"… {more} (enter to expand)" if body
|
|
227
|
+
else "… enter to expand"))
|
|
228
|
+
return rows
|
|
229
|
+
|
|
230
|
+
def _markup(self) -> str:
|
|
231
|
+
head = format.tool_start(self._start, show_subagent_suffix=self._show_suffix,
|
|
232
|
+
width=self._width_of(), failed=self._failed())
|
|
233
|
+
if self._repeat > 1:
|
|
234
|
+
head += f" [dim]×{self._repeat}[/dim]"
|
|
235
|
+
lines = [head]
|
|
236
|
+
for i, (style, text) in enumerate(self._result_rows()):
|
|
237
|
+
# `⎿` hangs off the call row once; every line after it aligns to
|
|
238
|
+
# the same column, so a multi-line result reads as one indented
|
|
239
|
+
# column of output rather than as more top-level rows.
|
|
240
|
+
prefix = f" [dim]{_RESULT_MARK}[/dim] " if i == 0 else _RESULT_INDENT
|
|
241
|
+
lines.append(f"{prefix}[{style}]{format.esc(text)}[/{style}]")
|
|
242
|
+
return "\n".join(lines)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class _Prose(Static):
|
|
246
|
+
"""An assistant prose block.
|
|
247
|
+
|
|
248
|
+
Flat, with no gutter mark of its own: `⏺` now belongs to tool calls, and
|
|
249
|
+
a second near-identical bullet on prose made the two read as one
|
|
250
|
+
undifferentiated column. Prose is what the transcript is FOR, so it gets
|
|
251
|
+
the left margin and the calls indent away from it.
|
|
252
|
+
|
|
253
|
+
Capped at a readable measure rather than the pane's full width -- a line
|
|
254
|
+
spanning a wide terminal costs the eye the return sweep on every wrap,
|
|
255
|
+
which is the single biggest readability tax a full-width transcript
|
|
256
|
+
imposes."""
|
|
257
|
+
|
|
258
|
+
DEFAULT_CSS = """
|
|
259
|
+
_Prose { max-width: 96; }
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class _EmptyState(Static):
|
|
264
|
+
DEFAULT_CSS = """
|
|
265
|
+
_EmptyState {
|
|
266
|
+
width: 100%;
|
|
267
|
+
height: 100%;
|
|
268
|
+
content-align: center middle;
|
|
269
|
+
text-align: center;
|
|
270
|
+
color: $text-muted;
|
|
271
|
+
}
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def __init__(self) -> None:
|
|
275
|
+
super().__init__(
|
|
276
|
+
"[bold]⌘ omega[/bold]\n"
|
|
277
|
+
"[dim]ask anything about this repo · /discuss to think together · "
|
|
278
|
+
"/plan to plan[/dim]")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class _PromptBand(Static):
|
|
282
|
+
"""The turn-opening user-prompt row: an accent-coloured `›` and the text,
|
|
283
|
+
flat on the page -- `add_user_message` builds the left/right-aligned text."""
|
|
284
|
+
|
|
285
|
+
DEFAULT_CSS = """
|
|
286
|
+
_PromptBand { width: 100%; margin: 1 0; }
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class _LiveStatus(Static):
|
|
291
|
+
"""The single running-turn indicator, docked to the pane's bottom edge so
|
|
292
|
+
it always stays below newly streamed content regardless of mount order."""
|
|
293
|
+
|
|
294
|
+
DEFAULT_CSS = """
|
|
295
|
+
_LiveStatus { dock: bottom; width: 100%; height: 1; padding: 0 1; }
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class _NewOutputPill(Static):
|
|
300
|
+
DEFAULT_CSS = """
|
|
301
|
+
_NewOutputPill {
|
|
302
|
+
dock: bottom;
|
|
303
|
+
width: 100%;
|
|
304
|
+
height: 1;
|
|
305
|
+
content-align: right middle;
|
|
306
|
+
color: $text-muted;
|
|
307
|
+
display: none;
|
|
308
|
+
}
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
def __init__(self, on_click: Callable[[], None]) -> None:
|
|
312
|
+
super().__init__("[dim]↓ new output[/dim]")
|
|
313
|
+
self._click_cb = on_click
|
|
314
|
+
|
|
315
|
+
def on_click(self, event: Click) -> None:
|
|
316
|
+
self._click_cb()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class Transcript(VerticalScroll):
|
|
320
|
+
DEFAULT_CSS = """
|
|
321
|
+
Transcript {
|
|
322
|
+
width: 1fr;
|
|
323
|
+
min-width: 40;
|
|
324
|
+
padding: 0 2;
|
|
325
|
+
}
|
|
326
|
+
Transcript > Static { margin-bottom: 1; }
|
|
327
|
+
Transcript > _LiveStatus, Transcript > _NewOutputPill { margin-bottom: 0; }
|
|
328
|
+
"""
|
|
329
|
+
BINDINGS = []
|
|
330
|
+
|
|
331
|
+
def __init__(self, *, id: str | None = None) -> None:
|
|
332
|
+
super().__init__(id=id)
|
|
333
|
+
self._live_assistant: Static | None = None
|
|
334
|
+
self._live_text: str = ""
|
|
335
|
+
self._session_id: str = ""
|
|
336
|
+
|
|
337
|
+
self._cursor_timer: Timer | None = None
|
|
338
|
+
self._cursor_frame = 0
|
|
339
|
+
|
|
340
|
+
self._empty_state: _EmptyState | None = None
|
|
341
|
+
self._last_kind: str | None = None
|
|
342
|
+
|
|
343
|
+
# live status footer (Part R5)
|
|
344
|
+
self._status_widget: Static | None = None
|
|
345
|
+
self._status_timer: Timer | None = None
|
|
346
|
+
self._status_frame = 0
|
|
347
|
+
self._phase = "idle"
|
|
348
|
+
self._turn_started: float | None = None
|
|
349
|
+
self._thinking_seconds = 0.0
|
|
350
|
+
self._thinking_entered: float | None = None
|
|
351
|
+
self._active_tool_count = 0
|
|
352
|
+
self._last_usage: events.Usage | None = None
|
|
353
|
+
|
|
354
|
+
self._top_group: _Group | None = None
|
|
355
|
+
self._sub_groups: dict[str, _Group] = {}
|
|
356
|
+
self._sub_spawn_widget: dict[str, Static] = {}
|
|
357
|
+
self._sub_task_text: dict[str, str] = {}
|
|
358
|
+
self._sub_start_time: dict[str, float] = {}
|
|
359
|
+
self._active_subagents: set[str] = set()
|
|
360
|
+
|
|
361
|
+
# Each live call's own block. A `_ToolBlock` holds the `ToolStart`
|
|
362
|
+
# behind its markup, so `on_resize` rebuilds every row against the
|
|
363
|
+
# pane's new width instead of leaving stale truncation baked in from
|
|
364
|
+
# whatever width was current when the row was first mounted.
|
|
365
|
+
self._call_block: dict[str, _ToolBlock] = {}
|
|
366
|
+
self._buffered_end: dict[str, events.ToolEnd] = {}
|
|
367
|
+
self._last_sig: tuple[str, str, str | None] | None = None
|
|
368
|
+
self._last_block: _ToolBlock | None = None
|
|
369
|
+
self._last_repeat = 1
|
|
370
|
+
self._latest_group: _Group | None = None
|
|
371
|
+
|
|
372
|
+
self._pill = _NewOutputPill(self.jump_to_end)
|
|
373
|
+
|
|
374
|
+
def on_mount(self) -> None:
|
|
375
|
+
self.mount(self._pill)
|
|
376
|
+
|
|
377
|
+
def set_session(self, session_id: str) -> None:
|
|
378
|
+
self._session_id = session_id
|
|
379
|
+
|
|
380
|
+
def jump_to_end(self) -> None:
|
|
381
|
+
self.scroll_end(animate=False)
|
|
382
|
+
self._pill.display = False
|
|
383
|
+
|
|
384
|
+
def _mount_widget(self, widget: Static) -> Static:
|
|
385
|
+
at_bottom = self.scroll_y >= self.max_scroll_y - 1
|
|
386
|
+
self.mount(widget)
|
|
387
|
+
if at_bottom:
|
|
388
|
+
self.scroll_end(animate=False)
|
|
389
|
+
self._pill.display = False
|
|
390
|
+
else:
|
|
391
|
+
self._pill.display = True
|
|
392
|
+
return widget
|
|
393
|
+
|
|
394
|
+
def _append(self, markup: str | Static) -> Static:
|
|
395
|
+
return self._mount_widget(markup if isinstance(markup, Static) else Static(markup))
|
|
396
|
+
|
|
397
|
+
def _ensure_gap(self, kind: str) -> None:
|
|
398
|
+
"""Records which kind of block is being started.
|
|
399
|
+
|
|
400
|
+
The blank line itself is CSS now (`Transcript > Static`'s
|
|
401
|
+
`margin-bottom`) rather than an empty spacer widget, so every block
|
|
402
|
+
breathes -- not only the ones that happen to follow a block of a
|
|
403
|
+
different kind. Mounting spacers on top of that margin double-spaced
|
|
404
|
+
the pane; the tracking stays because callers still read `_last_kind`
|
|
405
|
+
to decide block boundaries."""
|
|
406
|
+
self._last_kind = kind
|
|
407
|
+
|
|
408
|
+
# ---- empty state (Part R10) ---------------------------------------------
|
|
409
|
+
|
|
410
|
+
def show_empty_state(self) -> None:
|
|
411
|
+
self._empty_state = _EmptyState()
|
|
412
|
+
self.mount(self._empty_state)
|
|
413
|
+
|
|
414
|
+
def _hide_empty_state(self) -> None:
|
|
415
|
+
if self._empty_state is not None:
|
|
416
|
+
self._empty_state.remove()
|
|
417
|
+
self._empty_state = None
|
|
418
|
+
|
|
419
|
+
# ---- live status footer (Part R5) ---------------------------------------
|
|
420
|
+
|
|
421
|
+
def note_usage(self, ev: events.Usage) -> None:
|
|
422
|
+
self._last_usage = ev
|
|
423
|
+
self._refresh_status_now()
|
|
424
|
+
|
|
425
|
+
def _verb(self) -> str:
|
|
426
|
+
if self._active_tool_count > 0:
|
|
427
|
+
n = self._active_tool_count
|
|
428
|
+
return f"Running {n} tool{'' if n == 1 else 's'}…"
|
|
429
|
+
if self._active_subagents:
|
|
430
|
+
return "Waiting for subagent…"
|
|
431
|
+
if self._phase == "streaming":
|
|
432
|
+
return "Writing…"
|
|
433
|
+
return "Thinking…"
|
|
434
|
+
|
|
435
|
+
def _status_text(self) -> str:
|
|
436
|
+
frame = SPINNER_FRAMES[self._status_frame % len(SPINNER_FRAMES)]
|
|
437
|
+
elapsed = time.monotonic() - self._turn_started if self._turn_started else 0.0
|
|
438
|
+
thinking = self._thinking_seconds
|
|
439
|
+
if self._thinking_entered is not None:
|
|
440
|
+
thinking += time.monotonic() - self._thinking_entered
|
|
441
|
+
tokens = (self._last_usage.completion_tokens if self._last_usage is not None
|
|
442
|
+
else round(len(self._live_text) / 4))
|
|
443
|
+
bits = [f"{elapsed:.0f}s"]
|
|
444
|
+
if tokens:
|
|
445
|
+
bits.append(f"↓ {format.fmt_num(tokens)} tokens")
|
|
446
|
+
if thinking >= 1:
|
|
447
|
+
bits.append(f"thought for {thinking:.0f}s")
|
|
448
|
+
# The way out, stated where the waiting happens. It was documented
|
|
449
|
+
# only in `?`, which is the one screen nobody opens while a turn they
|
|
450
|
+
# want to stop is running.
|
|
451
|
+
bits.append("ctrl+c to interrupt")
|
|
452
|
+
return (f"[$accent]{frame}[/$accent] [$accent]{self._verb()}[/$accent] "
|
|
453
|
+
f"[dim]({' · '.join(bits)})[/dim]")
|
|
454
|
+
|
|
455
|
+
def _refresh_status_now(self) -> None:
|
|
456
|
+
if self._status_widget is not None:
|
|
457
|
+
self._status_widget.update(self._status_text())
|
|
458
|
+
|
|
459
|
+
def _tick_status(self) -> None:
|
|
460
|
+
self._status_frame += 1
|
|
461
|
+
self._refresh_status_now()
|
|
462
|
+
|
|
463
|
+
def _ensure_status(self) -> None:
|
|
464
|
+
if self._status_widget is None:
|
|
465
|
+
self._status_widget = _LiveStatus(self._status_text())
|
|
466
|
+
self.mount(self._status_widget)
|
|
467
|
+
self._status_timer = self.set_interval(0.08, self._tick_status)
|
|
468
|
+
|
|
469
|
+
def _stop_status(self) -> None:
|
|
470
|
+
if self._status_timer is not None:
|
|
471
|
+
self._status_timer.stop()
|
|
472
|
+
self._status_timer = None
|
|
473
|
+
if self._status_widget is not None:
|
|
474
|
+
self._status_widget.remove()
|
|
475
|
+
self._status_widget = None
|
|
476
|
+
|
|
477
|
+
def note_phase(self, state: str) -> None:
|
|
478
|
+
now = time.monotonic()
|
|
479
|
+
if self._phase == "thinking" and state != "thinking" and self._thinking_entered is not None:
|
|
480
|
+
self._thinking_seconds += now - self._thinking_entered
|
|
481
|
+
self._thinking_entered = None
|
|
482
|
+
if state == "thinking" and self._thinking_entered is None:
|
|
483
|
+
self._thinking_entered = now
|
|
484
|
+
if state == "waiting":
|
|
485
|
+
self._top_group = None
|
|
486
|
+
if state == "idle":
|
|
487
|
+
self._phase = state
|
|
488
|
+
self._stop_status()
|
|
489
|
+
self._stop_cursor()
|
|
490
|
+
self._turn_started = None
|
|
491
|
+
self._thinking_seconds = 0.0
|
|
492
|
+
self._thinking_entered = None
|
|
493
|
+
self._active_tool_count = 0
|
|
494
|
+
self._last_usage = None
|
|
495
|
+
return
|
|
496
|
+
if self._turn_started is None:
|
|
497
|
+
self._turn_started = now
|
|
498
|
+
self._phase = state
|
|
499
|
+
self._ensure_status()
|
|
500
|
+
self._refresh_status_now()
|
|
501
|
+
|
|
502
|
+
# ---- turn text ------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
def add_user_message(self, text: str, mode: str) -> None:
|
|
505
|
+
self._hide_empty_state()
|
|
506
|
+
self._ensure_gap("user")
|
|
507
|
+
# `content_size` excludes this widget's own padding/border, unlike
|
|
508
|
+
# `size` (the border box); `_PromptBand` adds none of its own.
|
|
509
|
+
width = self.content_size.width or 74
|
|
510
|
+
left = (f"[bold $accent]›[/bold $accent] [bold]{format.esc(text)}[/bold]"
|
|
511
|
+
f" [dim]{mode}[/dim]")
|
|
512
|
+
ts = time.strftime("%H:%M")
|
|
513
|
+
self._mount_widget(_PromptBand(format.right_align(left, f"[dim]{ts}[/dim]", width)))
|
|
514
|
+
|
|
515
|
+
def _code_theme(self) -> str:
|
|
516
|
+
return theme.current_code_theme(self.app)
|
|
517
|
+
|
|
518
|
+
def _cursor_markup(self) -> str:
|
|
519
|
+
style = _CURSOR_BEAT[self._cursor_frame % len(_CURSOR_BEAT)]
|
|
520
|
+
return f"[{style}]{_CURSOR}[/{style}]"
|
|
521
|
+
|
|
522
|
+
def _paint_live(self, cursor: str) -> None:
|
|
523
|
+
# Model text is data, not markup -- a stray "[/x]" must never raise.
|
|
524
|
+
if self._live_assistant is not None:
|
|
525
|
+
self._live_assistant.update(format.esc(self._live_text) + cursor)
|
|
526
|
+
|
|
527
|
+
def _beat_cursor(self) -> None:
|
|
528
|
+
self._cursor_frame += 1
|
|
529
|
+
self._paint_live(self._cursor_markup())
|
|
530
|
+
|
|
531
|
+
def _start_cursor(self) -> None:
|
|
532
|
+
if self._cursor_timer is None:
|
|
533
|
+
self._cursor_frame = 0
|
|
534
|
+
self._cursor_timer = self.set_interval(_CURSOR_INTERVAL, self._beat_cursor)
|
|
535
|
+
|
|
536
|
+
def _stop_cursor(self) -> None:
|
|
537
|
+
if self._cursor_timer is not None:
|
|
538
|
+
self._cursor_timer.stop()
|
|
539
|
+
self._cursor_timer = None
|
|
540
|
+
|
|
541
|
+
def add_text_delta(self, text: str) -> None:
|
|
542
|
+
if self._live_assistant is None:
|
|
543
|
+
self._ensure_gap("assistant")
|
|
544
|
+
self._live_text = ""
|
|
545
|
+
self._live_assistant = self._append(_Prose(""))
|
|
546
|
+
self._live_text += text
|
|
547
|
+
self._start_cursor()
|
|
548
|
+
self._paint_live(self._cursor_markup())
|
|
549
|
+
|
|
550
|
+
def _settle(self, widget: Static, text: str) -> None:
|
|
551
|
+
"""Retires a finished prose block: the cursor rests dim on the last
|
|
552
|
+
character for `_CURSOR_LINGER`, then the block re-renders as Markdown
|
|
553
|
+
without it. Deferred rather than immediate so the marker is still
|
|
554
|
+
there when the reader's eye arrives at the end of the sentence that
|
|
555
|
+
just finished."""
|
|
556
|
+
self._stop_cursor()
|
|
557
|
+
widget.update(format.esc(text) + f"[dim]{_CURSOR}[/dim]")
|
|
558
|
+
|
|
559
|
+
def finish() -> None:
|
|
560
|
+
widget.update(Markdown(text, code_theme=self._code_theme()))
|
|
561
|
+
|
|
562
|
+
self.set_timer(_CURSOR_LINGER, finish)
|
|
563
|
+
|
|
564
|
+
def _close_text_block(self) -> None:
|
|
565
|
+
"""Ends the CURRENT round's prose block by rendering it as Markdown in
|
|
566
|
+
place, so a `ToolStart`/`SubagentSpawned` between two rounds of text
|
|
567
|
+
never lets the second round's deltas keep appending onto the first
|
|
568
|
+
round's already-mounted widget (which is what put round-2 prose above
|
|
569
|
+
the tool calls that produced it, and ran its sentences together with
|
|
570
|
+
round 1's)."""
|
|
571
|
+
if self._live_assistant is None:
|
|
572
|
+
return
|
|
573
|
+
self._settle(self._live_assistant, self._live_text)
|
|
574
|
+
self._live_assistant = None
|
|
575
|
+
self._live_text = ""
|
|
576
|
+
|
|
577
|
+
def finalize_turn(self, text: str) -> None:
|
|
578
|
+
self._stop_status()
|
|
579
|
+
text = text or self._live_text
|
|
580
|
+
if not text:
|
|
581
|
+
self._stop_cursor()
|
|
582
|
+
return
|
|
583
|
+
if self._live_assistant is None:
|
|
584
|
+
self._ensure_gap("assistant")
|
|
585
|
+
self._live_assistant = self._append(_Prose(""))
|
|
586
|
+
self._settle(self._live_assistant, text)
|
|
587
|
+
self._live_assistant = None
|
|
588
|
+
self._live_text = ""
|
|
589
|
+
|
|
590
|
+
# ---- tool calls (C4/C7/C9, Part R2/R3) -------------------------------------
|
|
591
|
+
|
|
592
|
+
def _group_for(self, key: str | None) -> _Group:
|
|
593
|
+
if key is None:
|
|
594
|
+
if self._top_group is None:
|
|
595
|
+
self._top_group = _Group(is_sub=False)
|
|
596
|
+
return self._top_group
|
|
597
|
+
return self._sub_groups.setdefault(key, _Group(is_sub=True))
|
|
598
|
+
|
|
599
|
+
def _more_text(self, group: _Group) -> str:
|
|
600
|
+
remaining = group.total - group.shown
|
|
601
|
+
lead = " [dim]└ …" if group.is_sub else " [dim]…"
|
|
602
|
+
return f"{lead} +{remaining} more tool calls (g to expand)[/dim]"
|
|
603
|
+
|
|
604
|
+
def _detail_width(self) -> int | None:
|
|
605
|
+
# `content_size` already excludes this widget's own padding/border --
|
|
606
|
+
# unlike the old `self.size.width` (the border box), it doesn't need
|
|
607
|
+
# a guessed-at fudge factor to avoid overflow from double-counting
|
|
608
|
+
# them. The remaining `-24` budget covers the row's own chrome: the
|
|
609
|
+
# bullet/indent, the (never-truncated) name column for the handful of
|
|
610
|
+
# tool names longer than `pad_name`'s 12-column default, and a small
|
|
611
|
+
# margin of safety.
|
|
612
|
+
w = self.content_size.width
|
|
613
|
+
return max(10, w - 24) if w else None
|
|
614
|
+
|
|
615
|
+
def on_resize(self, event: Resize) -> None:
|
|
616
|
+
self._retruncate_tool_rows()
|
|
617
|
+
|
|
618
|
+
def _retruncate_tool_rows(self) -> None:
|
|
619
|
+
"""Rebuilds every currently-visible tool block against the pane's new
|
|
620
|
+
width -- without this, a row's `truncate_middle` cutoff stays fixed at
|
|
621
|
+
whatever width was current when it was first mounted, so shrinking the
|
|
622
|
+
pane (a live resize, or the sidebar opening) leaves rows wrapping
|
|
623
|
+
across extra lines instead of re-truncating to fit, and growing it
|
|
624
|
+
back leaves them stuck short of what would now fit."""
|
|
625
|
+
for block in dict.fromkeys(self._call_block.values()):
|
|
626
|
+
block.rerender()
|
|
627
|
+
|
|
628
|
+
def _body_width(self) -> int | None:
|
|
629
|
+
"""Width for the `│` rail's lines. Wider than `_detail_width` -- a
|
|
630
|
+
rail line pays only for the two-space indent and the rail itself, not
|
|
631
|
+
for the head row's bullet and name column."""
|
|
632
|
+
w = self.content_size.width
|
|
633
|
+
return max(20, w - len(_RESULT_INDENT) - 2) if w else None
|
|
634
|
+
|
|
635
|
+
def _new_block(self, ev: events.ToolStart, show_suffix: bool) -> _ToolBlock:
|
|
636
|
+
block = _ToolBlock(ev, show_suffix=show_suffix, width_of=self._detail_width,
|
|
637
|
+
body_width_of=self._body_width)
|
|
638
|
+
self._mount_widget(block)
|
|
639
|
+
self._call_block[ev.call_id] = block
|
|
640
|
+
end_ev = self._buffered_end.pop(ev.call_id, None)
|
|
641
|
+
if end_ev is not None:
|
|
642
|
+
block.note_end(end_ev)
|
|
643
|
+
return block
|
|
644
|
+
|
|
645
|
+
def add_tool_start(self, ev: events.ToolStart) -> None:
|
|
646
|
+
if ev.name == "subagent":
|
|
647
|
+
return
|
|
648
|
+
self._close_text_block()
|
|
649
|
+
self._active_tool_count += 1
|
|
650
|
+
self._refresh_status_now()
|
|
651
|
+
key = ev.subagent_id
|
|
652
|
+
sig = (ev.name, ev.args_preview, key)
|
|
653
|
+
if self._last_sig == sig and self._last_block is not None:
|
|
654
|
+
self._last_repeat += 1
|
|
655
|
+
self._last_block.note_repeat(self._last_repeat)
|
|
656
|
+
self._call_block[ev.call_id] = self._last_block
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
group = self._group_for(key)
|
|
660
|
+
group.total += 1
|
|
661
|
+
if group.shown < _GROUP_CAP:
|
|
662
|
+
group.shown += 1
|
|
663
|
+
if key is None:
|
|
664
|
+
self._ensure_gap("tools")
|
|
665
|
+
show_suffix = key is None or len(self._active_subagents) > 1
|
|
666
|
+
block = self._new_block(ev, show_suffix)
|
|
667
|
+
self._last_sig, self._last_block, self._last_repeat = sig, block, 1
|
|
668
|
+
else:
|
|
669
|
+
group.hidden.append(ev)
|
|
670
|
+
self._last_sig = None
|
|
671
|
+
self._last_block = None
|
|
672
|
+
if group.more is None:
|
|
673
|
+
if key is None:
|
|
674
|
+
self._ensure_gap("tools")
|
|
675
|
+
group.more = self._append_more(group)
|
|
676
|
+
else:
|
|
677
|
+
group.more.update(self._more_text(group))
|
|
678
|
+
self._latest_group = group
|
|
679
|
+
|
|
680
|
+
def _append_more(self, group: _Group) -> Static:
|
|
681
|
+
line = _MoreLine(self._more_text(group), lambda: self._expand_group(group))
|
|
682
|
+
return self._mount_widget(line)
|
|
683
|
+
|
|
684
|
+
def _expand_group(self, group: _Group) -> None:
|
|
685
|
+
if group.expanded or not group.hidden:
|
|
686
|
+
return
|
|
687
|
+
group.expanded = True
|
|
688
|
+
for start_ev in group.hidden:
|
|
689
|
+
show_suffix = start_ev.subagent_id is None or len(self._active_subagents) > 1
|
|
690
|
+
self._new_block(start_ev, show_suffix)
|
|
691
|
+
if group.more is not None:
|
|
692
|
+
group.more.remove()
|
|
693
|
+
group.more = None
|
|
694
|
+
group.hidden.clear()
|
|
695
|
+
|
|
696
|
+
def expand_latest(self) -> None:
|
|
697
|
+
if self._latest_group is not None:
|
|
698
|
+
self._expand_group(self._latest_group)
|
|
699
|
+
|
|
700
|
+
def add_tool_end(self, ev: events.ToolEnd) -> None:
|
|
701
|
+
if ev.name == "subagent":
|
|
702
|
+
return
|
|
703
|
+
self._active_tool_count = max(0, self._active_tool_count - 1)
|
|
704
|
+
self._refresh_status_now()
|
|
705
|
+
block = self._call_block.get(ev.call_id)
|
|
706
|
+
if block is None:
|
|
707
|
+
self._buffered_end[ev.call_id] = ev
|
|
708
|
+
return
|
|
709
|
+
# Always folded in, even with an empty outcome -- the result preview
|
|
710
|
+
# is worth showing on its own for a call too fast to earn a duration.
|
|
711
|
+
block.note_end(ev)
|
|
712
|
+
|
|
713
|
+
def add_subagent_spawned(self, ev: events.SubagentSpawned) -> None:
|
|
714
|
+
self._close_text_block()
|
|
715
|
+
self._active_subagents.add(ev.subagent_id)
|
|
716
|
+
self._sub_task_text[ev.subagent_id] = ev.task_preview
|
|
717
|
+
self._top_group = None
|
|
718
|
+
self._ensure_gap("tools")
|
|
719
|
+
markup = format.subagent_spawned(ev, show_id=len(self._active_subagents) > 1)
|
|
720
|
+
widget = self._append(markup)
|
|
721
|
+
self._sub_spawn_widget[ev.subagent_id] = widget
|
|
722
|
+
self._sub_start_time[ev.subagent_id] = time.monotonic()
|
|
723
|
+
self._refresh_status_now()
|
|
724
|
+
|
|
725
|
+
def add_subagent_done(self, ev: events.SubagentDone) -> None:
|
|
726
|
+
self._active_subagents.discard(ev.subagent_id)
|
|
727
|
+
widget = self._sub_spawn_widget.pop(ev.subagent_id, None)
|
|
728
|
+
task = self._sub_task_text.pop(ev.subagent_id, "")
|
|
729
|
+
started = self._sub_start_time.pop(ev.subagent_id, None)
|
|
730
|
+
self._refresh_status_now()
|
|
731
|
+
if widget is None:
|
|
732
|
+
return
|
|
733
|
+
elapsed = time.monotonic() - started if started is not None else 0.0
|
|
734
|
+
widget.update(format.subagent_done(ev, task_preview=task, elapsed_s=elapsed))
|
|
735
|
+
|
|
736
|
+
def add_compacted(self, ev: events.Compacted) -> None:
|
|
737
|
+
self._ensure_gap("other")
|
|
738
|
+
self._append(format.compacted(ev))
|
|
739
|
+
|
|
740
|
+
def add_memory_write(self, ev: events.MemoryWrite) -> None:
|
|
741
|
+
self._ensure_gap("other")
|
|
742
|
+
self._append(format.memory_write(ev))
|
|
743
|
+
|
|
744
|
+
def add_memory_consolidated(self, ev: events.MemoryConsolidated) -> None:
|
|
745
|
+
self._ensure_gap("other")
|
|
746
|
+
self._append(format.memory_consolidated(ev))
|
|
747
|
+
|
|
748
|
+
def add_error(self, ev: events.Error) -> None:
|
|
749
|
+
self._ensure_gap("other")
|
|
750
|
+
detail = f" [dim](details in ~/.omega/sessions/{self._session_id}.json)[/dim]" if self._session_id else ""
|
|
751
|
+
self._append(f"{format.error(ev)}{detail}")
|
|
752
|
+
|
|
753
|
+
def add_mode_switch(self, mode: str) -> None:
|
|
754
|
+
self._ensure_gap("other")
|
|
755
|
+
self._append(f"[dim]mode: {mode}[/dim]")
|
|
756
|
+
|
|
757
|
+
def add_resumed(self, session_id: str, turns: int, messages: int, cwd: str, ago: str = "") -> None:
|
|
758
|
+
self._ensure_gap("other")
|
|
759
|
+
suffix = f", last active {ago} ago" if ago else ""
|
|
760
|
+
self._append(f"[dim]resumed {format.esc(session_id)} — {turns} turns, {messages} messages"
|
|
761
|
+
f"{suffix} · {format.esc(cwd)}[/dim]")
|
|
762
|
+
|
|
763
|
+
def add_dim(self, text: str) -> None:
|
|
764
|
+
self._ensure_gap("other")
|
|
765
|
+
self._append(f"[dim]{format.esc(text)}[/dim]")
|
|
766
|
+
|
|
767
|
+
# ---- B1's edit-safety events (guarded in app.py -- see format.py) ------
|
|
768
|
+
|
|
769
|
+
def add_checkpoint(self, ev: Any) -> None:
|
|
770
|
+
self._ensure_gap("other")
|
|
771
|
+
self._append(format.checkpoint(ev))
|
|
772
|
+
|
|
773
|
+
def add_verified(self, ev: Any) -> None:
|
|
774
|
+
self._ensure_gap("other")
|
|
775
|
+
self._append(format.verified(ev))
|
|
776
|
+
|
|
777
|
+
def add_job_started(self, ev: Any) -> None:
|
|
778
|
+
self._ensure_gap("other")
|
|
779
|
+
self._append(format.job_started(ev))
|
|
780
|
+
|
|
781
|
+
def add_job_finished(self, ev: Any) -> None:
|
|
782
|
+
self._ensure_gap("other")
|
|
783
|
+
self._append(format.job_finished(ev))
|