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.
Files changed (73) hide show
  1. omega/__init__.py +0 -0
  2. omega/__main__.py +589 -0
  3. omega/artifacts.py +151 -0
  4. omega/checkpoint.py +246 -0
  5. omega/compact.py +106 -0
  6. omega/config.py +285 -0
  7. omega/eval/__init__.py +3 -0
  8. omega/eval/cli.py +127 -0
  9. omega/eval/examples/plan-version-flag.yaml +11 -0
  10. omega/eval/examples/relative-age-negative-delta.yaml +14 -0
  11. omega/eval/examples/version-flag.yaml +10 -0
  12. omega/eval/manifest.py +129 -0
  13. omega/eval/prices.py +29 -0
  14. omega/eval/report.py +135 -0
  15. omega/eval/runner.py +199 -0
  16. omega/eval/tasks.py +97 -0
  17. omega/events.py +145 -0
  18. omega/export.py +80 -0
  19. omega/gitlog.py +229 -0
  20. omega/hooks.py +63 -0
  21. omega/instructions.py +103 -0
  22. omega/integrations.py +284 -0
  23. omega/keys.py +173 -0
  24. omega/llm.py +442 -0
  25. omega/loop.py +510 -0
  26. omega/mcp.py +490 -0
  27. omega/memory/__init__.py +5 -0
  28. omega/memory/consolidate.py +103 -0
  29. omega/memory/curate.py +69 -0
  30. omega/memory/store.py +321 -0
  31. omega/memory/tools.py +175 -0
  32. omega/migrate.py +40 -0
  33. omega/onboarding.py +242 -0
  34. omega/permissions.py +137 -0
  35. omega/secrets.py +173 -0
  36. omega/server/__init__.py +7 -0
  37. omega/server/__main__.py +18 -0
  38. omega/server/app.py +71 -0
  39. omega/server/auth.py +73 -0
  40. omega/server/manager.py +287 -0
  41. omega/server/models.py +123 -0
  42. omega/server/tasks_api.py +311 -0
  43. omega/server/terminals.py +245 -0
  44. omega/server/worker.py +186 -0
  45. omega/session.py +209 -0
  46. omega/setup.html +281 -0
  47. omega/setup_server.py +452 -0
  48. omega/skills.py +158 -0
  49. omega/subagent.py +98 -0
  50. omega/tasks.py +195 -0
  51. omega/tools.py +590 -0
  52. omega/trace.py +156 -0
  53. omega/trajectory.py +146 -0
  54. omega/ui/__init__.py +0 -0
  55. omega/ui/composer.py +140 -0
  56. omega/ui/format.py +708 -0
  57. omega/ui/plain.py +141 -0
  58. omega/ui/tui/__init__.py +9 -0
  59. omega/ui/tui/app.py +958 -0
  60. omega/ui/tui/history.py +50 -0
  61. omega/ui/tui/modals.py +292 -0
  62. omega/ui/tui/onboarding.py +367 -0
  63. omega/ui/tui/prefs.py +25 -0
  64. omega/ui/tui/sidebar.py +510 -0
  65. omega/ui/tui/status.py +115 -0
  66. omega/ui/tui/theme.py +91 -0
  67. omega/ui/tui/transcript.py +783 -0
  68. omega/verify.py +133 -0
  69. omega_code-0.4.0.dist-info/METADATA +479 -0
  70. omega_code-0.4.0.dist-info/RECORD +73 -0
  71. omega_code-0.4.0.dist-info/WHEEL +4 -0
  72. omega_code-0.4.0.dist-info/entry_points.txt +2 -0
  73. 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))