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
omega/ui/tui/sidebar.py
ADDED
|
@@ -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]")
|