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,510 @@
1
+ """The right-hand panel: tabbed Session / Git / Connections. Closed by
2
+ default (`OmegaApp` starts it hidden); toggled with ctrl+b or `/sidebar`."""
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections import Counter
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+
11
+ from textual.app import ComposeResult
12
+ from textual.binding import Binding
13
+ from textual.containers import Container, VerticalScroll
14
+ from textual.events import Click, Resize
15
+ from textual.widgets import Collapsible, ContentSwitcher, Static
16
+
17
+ from ... import events, gitlog, session
18
+ from .. import format
19
+ from .status import SPINNER_FRAMES
20
+
21
+ TAB_IDS = ["tab-session", "tab-git", "tab-connections"]
22
+ TAB_LABELS = {"tab-session": "session", "tab-git": "git", "tab-connections": "connections"}
23
+ _TAB_GAP = 3
24
+
25
+ _STATUS_COLOR = {"M": "yellow", "A": "green", "D": "red", "R": "yellow", "?": "dim"}
26
+
27
+ _STATE_GLYPH = {
28
+ "connected": ("●", "green"),
29
+ "configured": ("○", "dim"),
30
+ "needs_auth": ("⚠", "yellow"),
31
+ "error": ("✗", "red"),
32
+ "disabled": ("⊘", "dim"),
33
+ }
34
+
35
+
36
+ def _extract_path(name: str, preview: str) -> str | None:
37
+ if name == "read":
38
+ rest = preview.removeprefix("read ")
39
+ return rest.split(":", 1)[0] if rest else None
40
+ if name == "write":
41
+ rest = preview.removeprefix("write ")
42
+ return rest.rsplit(" (", 1)[0] if " (" in rest else (rest or None)
43
+ if name == "edit":
44
+ rest = preview.removeprefix("edit ")
45
+ return rest or None
46
+ return None
47
+
48
+
49
+ def _extract_mcp_server(preview: str) -> str | None:
50
+ rest = preview.removeprefix("call_tool ")
51
+ return rest.split(":", 1)[0] if ":" in rest else None
52
+
53
+
54
+ def _section(title: str, width: int) -> str:
55
+ label = title.upper()
56
+ bar = "─" * max(1, width - len(label) - 1)
57
+ return f"[bold]{label}[/bold] [$rule]{bar}[/$rule]"
58
+
59
+
60
+ def _rule(width: int) -> str:
61
+ return f"[$rule]{'─' * max(1, width)}[/$rule]"
62
+
63
+
64
+ def _kv(rows: list[tuple[str, str]]) -> list[str]:
65
+ """Aligned `label value` lines: dim labels in one column, values in
66
+ the next, and a value's own newlines continue under the value column."""
67
+ if not rows:
68
+ return []
69
+ col = max(len(label) for label, _ in rows) + 2
70
+ lines = []
71
+ for label, value in rows:
72
+ first, *rest = value.split("\n")
73
+ lines.append(f"[dim]{label.ljust(col)}[/dim]{first}")
74
+ lines.extend(f"{' ' * col}{line}" for line in rest)
75
+ return lines
76
+
77
+
78
+ def _chips(counter: Counter[str]) -> str:
79
+ if not counter:
80
+ return "[dim](none)[/dim]"
81
+ chips = []
82
+ for name, c in counter.most_common():
83
+ style = format.style_for(name)
84
+ chips.append(f"[{style}]{name} ×{c}[/{style}]")
85
+ return " ".join(chips)
86
+
87
+
88
+ def _context_bar(used: int, limit: int, cells: int = 8) -> str:
89
+ """`▰▰▱▱▱▱▱▱ 10.4k / 1.0M · 1%` -- at least one cell fills as soon as
90
+ anything has been used, so a near-empty context window doesn't read as a
91
+ dead bar."""
92
+ if limit <= 0:
93
+ return f"{'▱' * cells} {format.fmt_num(used)} / –"
94
+ pct = used / limit
95
+ filled = max(1, round(pct * cells)) if used > 0 else 0
96
+ filled = min(cells, filled)
97
+ bar = "▰" * filled + "▱" * (cells - filled)
98
+ return f"{bar} {format.fmt_num(used)} / {format.fmt_num(limit)} · {pct * 100:.0f}%"
99
+
100
+
101
+ def _files_block(files: dict[str, set[str]], limit: int = 15) -> str:
102
+ if not files:
103
+ return "[dim](none)[/dim]"
104
+ rows = []
105
+ for path, kinds in list(files.items())[:limit]:
106
+ mark = "".join(sorted(kinds)).ljust(2)
107
+ color = "yellow" if "W" in kinds else "cyan"
108
+ rows.append(f"[{color}]{mark}[/{color}] {format.esc(path)}")
109
+ more = len(files) - limit
110
+ text = "\n".join(rows)
111
+ if more > 0:
112
+ text += f"\n[dim]+{more} more[/dim]"
113
+ return text
114
+
115
+
116
+ class SessionTab(VerticalScroll):
117
+ def __init__(self, sess: session.Session, *, touched: set[str] | None = None,
118
+ id: str | None = None) -> None:
119
+ super().__init__(id=id)
120
+ self._sess = sess
121
+ self._body = Static("")
122
+ self._tool_turn: Counter[str] = Counter()
123
+ self._tool_session: Counter[str] = Counter()
124
+ self._files_turn: dict[str, set[str]] = {}
125
+ self._files_session: dict[str, set[str]] = {}
126
+ # call_id -> (path, R/W), committed into the files dicts above only on
127
+ # a non-error ToolEnd -- a failed read/write must not show up as a
128
+ # file "touched" this turn.
129
+ self._pending_files: dict[str, tuple[str, str]] = {}
130
+ self._subagents_turn: list[tuple[str, str]] = []
131
+ self._sub_start: dict[str, float] = {}
132
+ self._memory_recalls = 0
133
+ self._memory_writes = 0
134
+ self._artifacts = 0
135
+ self._mcp_servers: set[str] = set()
136
+ self._usage: events.Usage | None = None
137
+ # alias -> {"prompt": n, "completion": n}, priced against
138
+ # `omega.eval.prices` for the MODEL card's running "$ so far" -- kept
139
+ # here rather than read off `OmegaApp` so this widget stays paintable
140
+ # from its own recorded events alone.
141
+ self._usage_totals: dict[str, dict[str, int]] = {}
142
+ self._last_model: tuple[str | None, str] = (None, "?")
143
+ self._context_limit = 0
144
+ self._touched = touched if touched is not None else set()
145
+ self._turn_started: float | None = None
146
+ self._turn_elapsed = 0.0
147
+
148
+ def compose(self) -> ComposeResult:
149
+ yield self._body
150
+
151
+ def on_mount(self) -> None:
152
+ self.set_interval(1, self._repaint)
153
+ self._repaint()
154
+
155
+ def reset_turn(self) -> None:
156
+ self._tool_turn.clear()
157
+ self._files_turn.clear()
158
+ self._pending_files.clear()
159
+ self._subagents_turn.clear()
160
+ self._turn_started = time.monotonic()
161
+ self._turn_elapsed = 0.0
162
+ self._repaint()
163
+
164
+ def note_phase(self, state: str) -> None:
165
+ """Freezes THIS TURN's elapsed time once the turn goes idle -- the
166
+ 1s repaint interval would otherwise keep advancing it forever."""
167
+ if state == "idle" and self._turn_started is not None:
168
+ self._turn_elapsed = time.monotonic() - self._turn_started
169
+ self._turn_started = None
170
+ self._repaint()
171
+
172
+ def _turn_elapsed_seconds(self) -> float:
173
+ if self._turn_started is not None:
174
+ return time.monotonic() - self._turn_started
175
+ return self._turn_elapsed
176
+
177
+ def set_session(self, sess: session.Session) -> None:
178
+ """`/sessions`: point this tab at a freshly resumed session -- turn
179
+ and file trackers reset since they describe the OLD session's turn."""
180
+ self._sess = sess
181
+ self.reset_turn()
182
+
183
+ def record_model(self, alias: str | None, model: str, *, limit: int | None = None) -> None:
184
+ self._last_model = (alias, model)
185
+ if limit is not None:
186
+ self._context_limit = limit
187
+ self._repaint()
188
+
189
+ def record_usage(self, ev: events.Usage) -> None:
190
+ self._usage = ev
191
+ alias = self._last_model[0] or self._last_model[1]
192
+ totals = self._usage_totals.setdefault(alias, {"prompt": 0, "completion": 0})
193
+ totals["prompt"] += ev.prompt_tokens
194
+ totals["completion"] += ev.completion_tokens
195
+ self._repaint()
196
+
197
+ def record_tool_start(self, ev: events.ToolStart) -> None:
198
+ self._tool_turn[ev.name] += 1
199
+ self._tool_session[ev.name] += 1
200
+ path = _extract_path(ev.name, ev.args_preview)
201
+ if path:
202
+ self._pending_files[ev.call_id] = (path, "R" if ev.name == "read" else "W")
203
+ if ev.name == "call_tool":
204
+ server = _extract_mcp_server(ev.args_preview)
205
+ if server:
206
+ self._mcp_servers.add(server)
207
+ if ev.name == "recall":
208
+ self._memory_recalls += 1
209
+ elif ev.name in ("remember", "supersede", "link"):
210
+ self._memory_writes += 1
211
+ self._repaint()
212
+
213
+ def record_tool_end(self, ev: events.ToolEnd) -> None:
214
+ if ev.offloaded:
215
+ self._artifacts += 1
216
+ pending = self._pending_files.pop(ev.call_id, None)
217
+ if pending is not None and not ev.outcome.startswith("→ error"):
218
+ path, kind = pending
219
+ for bucket in (self._files_turn, self._files_session):
220
+ bucket.setdefault(path, set()).add(kind)
221
+ if kind == "W":
222
+ expanded = Path(path).expanduser()
223
+ absolute = expanded if expanded.is_absolute() else Path(self._sess.cwd) / expanded
224
+ self._touched.add(str(absolute.resolve()))
225
+ self._repaint()
226
+
227
+ def record_subagent_spawned(self, ev: events.SubagentSpawned) -> None:
228
+ self._subagents_turn.append((ev.subagent_id, ev.tier))
229
+ self._sub_start[ev.subagent_id] = time.monotonic()
230
+ self._repaint()
231
+
232
+ def record_subagent_done(self, ev: events.SubagentDone) -> None:
233
+ self._sub_start.pop(ev.subagent_id, None)
234
+ self._repaint()
235
+
236
+ def _cost_so_far(self) -> float:
237
+ from ...eval import prices
238
+
239
+ total = 0.0
240
+ for alias, t in self._usage_totals.items():
241
+ cost = prices.estimate_cost(alias, t["prompt"], t["completion"])
242
+ if cost is not None:
243
+ total += cost
244
+ return total
245
+
246
+ def _session_tokens(self) -> int:
247
+ return sum(t["prompt"] + t["completion"] for t in self._usage_totals.values())
248
+
249
+ def _repaint(self) -> None:
250
+ width = self.size.width or 28
251
+ alias, model = self._last_model
252
+ used = self._usage.used if self._usage else 0
253
+ limit = self._usage.limit if self._usage else self._context_limit
254
+
255
+ lines = [_section("model", width)]
256
+ lines += _kv([
257
+ ("model", f"[bold]{format.esc(alias)}[/bold] [dim]{format.esc(model)}[/dim]"
258
+ if alias else format.esc(model)),
259
+ ("context", _context_bar(used, limit)),
260
+ ("cost", f"${self._cost_so_far():.2f}"),
261
+ ])
262
+ lines.append("")
263
+
264
+ lines.append(_section("this turn", width))
265
+ turn_rows = [("elapsed", f"{self._turn_elapsed_seconds():.0f}s")]
266
+ if self._tool_turn:
267
+ turn_rows.append(("tools", _chips(self._tool_turn)))
268
+ if self._files_turn:
269
+ turn_rows.append(("files", _files_block(self._files_turn)))
270
+ if self._subagents_turn:
271
+ turn_rows.append(("agents", "\n".join(self._subagent_lines())))
272
+ lines += _kv(turn_rows)
273
+ lines.append("")
274
+
275
+ lines.append(_section("totals", width))
276
+ total_rows = [
277
+ ("turns", str(self._sess.turns)),
278
+ ("tokens", format.fmt_num(self._session_tokens())),
279
+ ("tools", str(sum(self._tool_session.values()))),
280
+ ("artifacts", str(self._artifacts)),
281
+ ("memory", f"{self._memory_recalls} recalls · {self._memory_writes} writes"),
282
+ ]
283
+ if self._files_session:
284
+ total_rows.append(("files", _files_block(self._files_session)))
285
+ if self._mcp_servers:
286
+ total_rows.append(("mcp", ", ".join(sorted(self._mcp_servers))))
287
+ lines += _kv(total_rows)
288
+ self._body.update("\n".join(lines))
289
+
290
+ def _subagent_lines(self) -> list[str]:
291
+ out = []
292
+ for sid, tier in self._subagents_turn:
293
+ started = self._sub_start.get(sid)
294
+ if started is not None:
295
+ elapsed = time.monotonic() - started
296
+ frame = SPINNER_FRAMES[int(elapsed / 0.08) % len(SPINNER_FRAMES)]
297
+ out.append(f"[$accent]{frame}[/$accent] {format.esc(tier)} · "
298
+ f"[dim]{format.esc(sid)} · {elapsed:.0f}s[/dim]")
299
+ else:
300
+ out.append(f"[green]✓[/green] {format.esc(tier)} · [dim]{format.esc(sid)}[/dim]")
301
+ return out
302
+
303
+
304
+ class _ChangeRow(Static, can_focus=True):
305
+ """One `git status` row: focusable, opens the file's diff on enter/click."""
306
+
307
+ DEFAULT_CSS = """
308
+ _ChangeRow { height: 1; }
309
+ _ChangeRow:focus { text-style: underline; }
310
+ """
311
+ BINDINGS = [Binding("enter", "activate", show=False)]
312
+
313
+ def __init__(self, repo: gitlog.Repo, change: gitlog.Change, touched: bool) -> None:
314
+ color = _STATUS_COLOR.get(change.status, "dim")
315
+ counts = ""
316
+ if change.status == "M" and (change.added or change.removed):
317
+ counts = f" [dim]+{change.added} −{change.removed}[/dim]"
318
+ elif change.status == "A" and change.added:
319
+ counts = f" [dim]+{change.added}[/dim]"
320
+ mark = " [$accent]●[/$accent]" if touched else ""
321
+ super().__init__(f"[{color}]{change.status}[/{color}] {format.esc(change.path)}{counts}{mark}")
322
+ self.repo = repo
323
+ self.path = change.path
324
+
325
+ def on_click(self, event: Click) -> None:
326
+ self.action_activate()
327
+
328
+ def action_activate(self) -> None:
329
+ from .modals import DiffScreen
330
+ self.app.push_screen(DiffScreen(self.repo, self.path))
331
+
332
+
333
+ class GitTab(VerticalScroll):
334
+ def __init__(self, cwd: str, *, touched: set[str] | None = None, id: str | None = None) -> None:
335
+ super().__init__(id=id)
336
+ self._cwd = cwd
337
+ self._touched = touched if touched is not None else set()
338
+
339
+ def on_mount(self) -> None:
340
+ self.mount(Static("[dim]loading…[/dim]"))
341
+ self.refresh_repos()
342
+
343
+ def refresh_repos(self) -> None:
344
+ self.run_worker(self._load(), exclusive=True)
345
+
346
+ def _is_touched(self, repo: gitlog.Repo, path: str) -> bool:
347
+ return str((repo.path / path).resolve()) in self._touched
348
+
349
+ async def _load(self) -> None:
350
+ repos = await gitlog.discover_repos_async(Path(self._cwd))
351
+ await self.remove_children()
352
+ if not repos:
353
+ self.mount(Static(f"[dim]no git repositories under {format.esc(self._cwd)}[/dim]"))
354
+ return
355
+ for i, repo in enumerate(repos):
356
+ if i:
357
+ self.mount(Static(""))
358
+ await self._mount_repo(repo)
359
+
360
+ async def _mount_repo(self, repo: gitlog.Repo) -> None:
361
+ changes = await gitlog.working_tree_async(repo)
362
+ commits = await gitlog.recent_commits_async(repo, limit=10)
363
+ width = self.size.width or 28
364
+ dirty = " [yellow]●[/yellow] [dim]dirty[/dim]" if repo.dirty else ""
365
+ self.mount(Static(f"[bold]{format.esc(repo.name)}[/bold] "
366
+ f"[dim]{format.esc(repo.branch)}[/dim]{dirty}"))
367
+ self.mount(Static(_section("changes", width)))
368
+ if not changes:
369
+ self.mount(Static("[dim] (clean)[/dim]"))
370
+ else:
371
+ for change in changes:
372
+ self.mount(_ChangeRow(repo, change, touched=self._is_touched(repo, change.path)))
373
+ history_body = "\n".join(
374
+ f"[$accent]{format.esc(c.short_sha)}[/$accent] "
375
+ f"[dim]{format.esc(c.age)} {format.esc(c.subject)}[/dim]" for c in commits
376
+ ) or "[dim](no commits)[/dim]"
377
+ self.mount(Collapsible(Static(history_body), title="HISTORY", collapsed=True))
378
+
379
+
380
+ class ConnectionsTab(VerticalScroll):
381
+ def __init__(self, *, id: str | None = None) -> None:
382
+ super().__init__(id=id)
383
+ self._header = Static("")
384
+ self._body = Static("[dim]loading…[/dim]")
385
+
386
+ def compose(self) -> ComposeResult:
387
+ yield self._header
388
+ yield self._body
389
+
390
+ def on_mount(self) -> None:
391
+ self.refresh_status()
392
+
393
+ def refresh_status(self) -> None:
394
+ self._header.update(_section("connections", self.size.width or 28))
395
+ self.run_worker(self._load(), exclusive=True)
396
+
397
+ async def _load(self) -> None:
398
+ from ... import mcp
399
+ try:
400
+ statuses = await asyncio.to_thread(mcp.status)
401
+ except Exception:
402
+ self._body.update("[dim]no integrations connected[/dim]")
403
+ return
404
+ if not statuses:
405
+ self._body.update("[dim]no integrations connected[/dim]")
406
+ return
407
+ lines = []
408
+ for name, st in sorted(statuses.items()):
409
+ glyph, color = _STATE_GLYPH.get(st.state, ("●", "dim"))
410
+ bits = [f"[{color}]{glyph}[/{color}] {format.esc(name)}"]
411
+ if st.state == "connected":
412
+ bits.append(f"[dim]{st.tools} tools[/dim]")
413
+ if st.last_used:
414
+ bits.append(f"[dim]· used {format.relative_age(time.time() - st.last_used)} ago[/dim]")
415
+ elif st.state == "configured":
416
+ bits.append("[dim]connects on first use[/dim]")
417
+ elif st.state == "needs_auth":
418
+ bits.append(f"[dim]omega connections connect {format.esc(name)}[/dim]")
419
+ elif st.state == "error":
420
+ bits.append(f"[dim]{format.esc((st.error or '')[:60])}[/dim]")
421
+ lines.append(" ".join(bits))
422
+ self._body.update("\n".join(lines))
423
+
424
+
425
+ class _TabStrip(Static):
426
+ """The panel's own flat tab row -- `SESSION GIT CONNECTIONS` with the
427
+ active one in bold, over a hairline that continues the header's rule
428
+ across the divider. Click a label to switch, same as ctrl+1/2/3."""
429
+
430
+ DEFAULT_CSS = """
431
+ _TabStrip { height: 2; }
432
+ """
433
+
434
+ def __init__(self, on_pick: Callable[[str], None]) -> None:
435
+ super().__init__()
436
+ self._active = TAB_IDS[0]
437
+ self._spans: list[tuple[int, int, str]] = []
438
+ self._on_pick = on_pick
439
+
440
+ def set_active(self, tab_id: str) -> None:
441
+ self._active = tab_id
442
+ self._repaint()
443
+
444
+ def on_mount(self) -> None:
445
+ self._repaint()
446
+
447
+ def on_resize(self, event: Resize) -> None:
448
+ self._repaint()
449
+
450
+ def on_click(self, event: Click) -> None:
451
+ for start, end, tab_id in self._spans:
452
+ if start <= event.x < end:
453
+ self._on_pick(tab_id)
454
+ return
455
+
456
+ def _repaint(self) -> None:
457
+ width = self.size.width or 28
458
+ parts, self._spans, x = [], [], 0
459
+ for tab_id in TAB_IDS:
460
+ label = TAB_LABELS[tab_id].upper()
461
+ style = "bold" if tab_id == self._active else "dim"
462
+ parts.append(f"[{style}]{label}[/{style}]")
463
+ self._spans.append((x, x + len(label), tab_id))
464
+ x += len(label) + _TAB_GAP
465
+ self.update((" " * _TAB_GAP).join(parts) + "\n" + _rule(width))
466
+
467
+
468
+ class Sidebar(Container):
469
+ DEFAULT_CSS = """
470
+ Sidebar {
471
+ width: 34;
472
+ max-width: 40%;
473
+ border-left: solid $rule;
474
+ padding: 0 0 0 1;
475
+ }
476
+ Sidebar ContentSwitcher { height: 1fr; margin-top: 1; }
477
+ """
478
+
479
+ def __init__(self, sess: session.Session, *, id: str | None = None) -> None:
480
+ super().__init__(id=id)
481
+ self._touched: set[str] = set()
482
+ self.session_tab = SessionTab(sess, touched=self._touched, id=TAB_IDS[0])
483
+ self.git_tab = GitTab(sess.cwd, touched=self._touched, id=TAB_IDS[1])
484
+ self.connections_tab = ConnectionsTab(id=TAB_IDS[2])
485
+ self._strip = _TabStrip(self._set_active)
486
+ self._cwd = sess.cwd
487
+
488
+ def compose(self) -> ComposeResult:
489
+ yield self._strip
490
+ with ContentSwitcher(initial=TAB_IDS[0]):
491
+ yield self.session_tab
492
+ yield self.git_tab
493
+ yield self.connections_tab
494
+
495
+ @property
496
+ def active(self) -> str:
497
+ current = self.query_one(ContentSwitcher).current
498
+ return current if current in TAB_IDS else TAB_IDS[0]
499
+
500
+ def _set_active(self, tab_id: str) -> None:
501
+ self.query_one(ContentSwitcher).current = tab_id
502
+ self._strip.set_active(tab_id)
503
+
504
+ def cycle_tab(self, forward: bool = True) -> None:
505
+ idx = TAB_IDS.index(self.active)
506
+ self._set_active(TAB_IDS[(idx + (1 if forward else -1)) % len(TAB_IDS)])
507
+
508
+ def show_tab(self, index: int) -> None:
509
+ if 0 <= index < len(TAB_IDS):
510
+ self._set_active(TAB_IDS[index])
omega/ui/tui/status.py ADDED
@@ -0,0 +1,115 @@
1
+ """The chrome line below the input: status cells folded in with the key
2
+ hints, per the reference terminal-agent layout (Part R7) -- there is no
3
+ separate status bar above the input any more."""
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+
8
+ from textual.events import Resize
9
+ from textual.widgets import Static
10
+
11
+ from ... import events
12
+ from .. import format as ui_format
13
+
14
+ # Advanced every 80ms via `set_interval` while the turn is not idle.
15
+ SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class StatusState:
20
+ mode: str
21
+ role_name: str
22
+ model: str
23
+ session_id: str
24
+ turns: int
25
+ usage: events.Usage | None
26
+ alias: str | None = None
27
+ phase: str = "idle"
28
+ sidebar_auto_hidden: bool = False
29
+ note: str = ""
30
+
31
+
32
+ def _fmt_usage(usage: events.Usage | None) -> str:
33
+ """`10.4k/1.0M` -- `ui_format.fmt_num` switches to the `M` scale above a
34
+ million, unlike a bare `n/1000` division that would print `1048.6k`."""
35
+ if usage is None:
36
+ return "–"
37
+ return f"{ui_format.fmt_num(usage.used)}/{ui_format.fmt_num(usage.limit)}"
38
+
39
+
40
+ def _fmt_cache(usage: events.Usage | None) -> str:
41
+ """`cache 87%` = cache_read / prompt_tokens for the last turn -- omitted
42
+ entirely (not just zeroed) when there was nothing to read from cache."""
43
+ if usage is None or usage.cache_read <= 0 or usage.prompt_tokens <= 0:
44
+ return ""
45
+ pct = usage.cache_read / usage.prompt_tokens * 100
46
+ return f" · [dim]cache {pct:.0f}%[/dim]"
47
+
48
+
49
+ HINT_TEXT = "ctrl+b panel · ctrl+o model · shift+tab mode · /help"
50
+ _SIDEBAR_HIDDEN_HINT = "panel hidden <80c · ctrl+o model · shift+tab mode · /help"
51
+ # A much shorter fallback for when the pane is too narrow for even
52
+ # `HINT_TEXT` -- the panel being auto-hidden is worth a nudge on its own,
53
+ # even where the full cheat-sheet hint has already been dropped for space.
54
+ _SIDEBAR_HIDDEN_SHORT_HINT = "panel hidden <80c"
55
+
56
+
57
+ def _hint_text(state: StatusState) -> str:
58
+ return _SIDEBAR_HIDDEN_HINT if state.sidebar_auto_hidden else HINT_TEXT
59
+
60
+
61
+ def format_status(state: StatusState, *, width: int | None = None) -> str:
62
+ tokens = _fmt_usage(state.usage)
63
+ cache = _fmt_cache(state.usage)
64
+ model = f"{state.alias} · {state.model}" if state.alias else state.model
65
+ full = f" {state.mode} · {model} · {tokens}{cache}{state.note}"
66
+ if width is None or len(full) <= width:
67
+ return full
68
+ # Narrow terminal: drop the raw model id first -- the alias alone still
69
+ # says which model is in use.
70
+ alias_only = f" {state.mode} · {state.alias or state.model} · {tokens}{cache}{state.note}"
71
+ return alias_only if len(alias_only) <= width else alias_only[:width]
72
+
73
+
74
+ class StatusBar(Static):
75
+ """One dim chrome line: status cells on the left, key hints on the
76
+ right -- the live phase/spinner now lives in the transcript's own
77
+ running-turn line (Part R5), not duplicated here."""
78
+
79
+ DEFAULT_CSS = """
80
+ StatusBar {
81
+ height: 1;
82
+ color: $text-muted;
83
+ padding: 0 1;
84
+ }
85
+ """
86
+
87
+ def __init__(self, *, id: str | None = None) -> None:
88
+ super().__init__(id=id)
89
+ self._state: StatusState | None = None
90
+
91
+ def set_state(self, state: StatusState) -> None:
92
+ self._state = state
93
+ self._repaint()
94
+
95
+ def on_resize(self, event: Resize) -> None:
96
+ self._repaint()
97
+
98
+ def _repaint(self) -> None:
99
+ if self._state is None:
100
+ return
101
+ width = self.size.width or 78
102
+ full = format_status(self._state, width=None).strip()
103
+ hint = _hint_text(self._state)
104
+ # The status cells always win the space fight -- the hint text is a
105
+ # nice-to-have that only shows up once there's room left over for it.
106
+ if ui_format.visible_len(full) + len(hint) + 3 <= width:
107
+ self.update(ui_format.right_align(f"[dim]{full}[/dim]", f"[dim]{hint}[/dim]", width))
108
+ return
109
+ narrowed = format_status(self._state, width=width).strip()
110
+ if (self._state.sidebar_auto_hidden
111
+ and ui_format.visible_len(narrowed) + len(_SIDEBAR_HIDDEN_SHORT_HINT) + 2 <= width):
112
+ self.update(ui_format.right_align(
113
+ f"[dim]{narrowed}[/dim]", f"[dim]{_SIDEBAR_HIDDEN_SHORT_HINT}[/dim]", width))
114
+ return
115
+ self.update(f"[dim]{narrowed}[/dim]")