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/app.py
ADDED
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
"""OmegaApp: composition, key bindings, and turn execution. Rendering logic
|
|
2
|
+
lives in transcript.py/sidebar.py/status.py; this module only translates
|
|
3
|
+
events into calls on their typed widget methods, never the reverse."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import difflib
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
from textual.app import App, ComposeResult
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
from textual.containers import Horizontal, Vertical
|
|
15
|
+
from textual.events import Key, Paste, Resize
|
|
16
|
+
from textual.screen import ModalScreen
|
|
17
|
+
from textual.timer import Timer
|
|
18
|
+
from textual.widgets import Static, TextArea
|
|
19
|
+
from textual.worker import Worker
|
|
20
|
+
|
|
21
|
+
from ... import compact, config, events, export, gitlog, loop, session, trace
|
|
22
|
+
from ...memory import consolidate
|
|
23
|
+
from .. import composer as composer_lib
|
|
24
|
+
from .. import format
|
|
25
|
+
from . import prefs, theme
|
|
26
|
+
from .history import InputHistory
|
|
27
|
+
from .modals import AskUserScreen, ConfirmScreen, DiffScreen, ModelPickerScreen, SessionsScreen
|
|
28
|
+
from .sidebar import Sidebar
|
|
29
|
+
from .status import StatusBar, StatusState
|
|
30
|
+
from .transcript import Transcript
|
|
31
|
+
|
|
32
|
+
_MODE_ROLE = {"build": "main", "plan": "plan", "discuss": "discuss"}
|
|
33
|
+
_MODE_TAG_STYLE = {"build": "dim", "plan": "yellow", "discuss": "$accent"}
|
|
34
|
+
_MODE_CYCLE = ["build", "plan", "discuss"]
|
|
35
|
+
|
|
36
|
+
# Below this total terminal width the side panel is auto-hidden (regardless
|
|
37
|
+
# of the user's own toggle preference) rather than squeezed down to
|
|
38
|
+
# illegibility -- `Sidebar`'s own `max-width: 40%` still applies above it.
|
|
39
|
+
_MIN_WIDTH_FOR_SIDEBAR = 80
|
|
40
|
+
|
|
41
|
+
_COMMANDS = ["/plan", "/build", "/discuss", "/mode", "/memory-gc", "/model", "/sidebar",
|
|
42
|
+
"/theme", "/cost", "/export", "/compact", "/undo", "/diff", "/verify",
|
|
43
|
+
"/sessions", "/help", "/quit"]
|
|
44
|
+
|
|
45
|
+
_HELP_TEXT = (
|
|
46
|
+
"ctrl+c cancel turn · ctrl+o /model switch model · ctrl+b toggle side panel · "
|
|
47
|
+
"shift+tab cycle mode\n"
|
|
48
|
+
"ctrl+1/2/3 or [ ] switch side-panel tab · up/down input history\n"
|
|
49
|
+
"input line editing: ctrl+u clear line · ctrl+w/alt+backspace delete word back · "
|
|
50
|
+
"alt+d delete word forward\n"
|
|
51
|
+
"ctrl+k delete to end · ctrl+a/ctrl+e home/end · alt+left/right word jump · "
|
|
52
|
+
"esc clear input (idle only)\n"
|
|
53
|
+
"/plan /build /discuss switch modes · /model <alias> · /mode · /sidebar · "
|
|
54
|
+
"/theme system|light|dark · /memory-gc\n"
|
|
55
|
+
"/cost tokens & $ · /export [path] · /compact · /undo [n] · /diff · /verify · "
|
|
56
|
+
"/sessions · /quit")
|
|
57
|
+
|
|
58
|
+
# `events.Checkpoint`/`Verified`/`JobStarted`/`JobFinished` are B1's, added to
|
|
59
|
+
# events.py concurrently with this file -- resolved once at import time so
|
|
60
|
+
# `emit()` below can dispatch to them without a hard import dependency.
|
|
61
|
+
_Checkpoint = getattr(events, "Checkpoint", None)
|
|
62
|
+
_Verified = getattr(events, "Verified", None)
|
|
63
|
+
_JobStarted = getattr(events, "JobStarted", None)
|
|
64
|
+
_JobFinished = getattr(events, "JobFinished", None)
|
|
65
|
+
|
|
66
|
+
# Git branch per cwd, cached for the life of the process and refreshed
|
|
67
|
+
# explicitly at turn end -- a `git symbolic-ref` per header repaint would be
|
|
68
|
+
# wasteful for something that only ever changes between turns.
|
|
69
|
+
_branch_cache: dict[str, str] = {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def _lookup_branch(cwd: str) -> str:
|
|
73
|
+
path = Path(cwd)
|
|
74
|
+
for d in (path, *path.parents):
|
|
75
|
+
if (d / ".git").exists():
|
|
76
|
+
return await asyncio.to_thread(gitlog._branch, d)
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class HeaderBar(Static):
|
|
81
|
+
"""The top chrome line: wordmark, cwd, git branch, session id -- all dim
|
|
82
|
+
but the wordmark (Part R9).
|
|
83
|
+
|
|
84
|
+
Fixed at `height: 1`, and rich's default word-wrap can push an
|
|
85
|
+
over-length "word" (an unbroken path has no internal spaces to wrap on)
|
|
86
|
+
onto a second line wholesale rather than character-cropping it -- with
|
|
87
|
+
height pinned to 1, that pushed-down line is never shown, so the entire
|
|
88
|
+
cwd/branch/session tail could silently vanish the moment it didn't fit.
|
|
89
|
+
Truncating the tail ourselves, from the right, against the bar's actual
|
|
90
|
+
width keeps this a single guaranteed-fitting line instead."""
|
|
91
|
+
|
|
92
|
+
DEFAULT_CSS = """
|
|
93
|
+
HeaderBar { height: 2; color: $text-muted; padding: 0 1; }
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
_PREFIX = "⌘ omega "
|
|
97
|
+
|
|
98
|
+
def __init__(self, *, id: str | None = None) -> None:
|
|
99
|
+
super().__init__(id=id)
|
|
100
|
+
self._cwd = ""
|
|
101
|
+
self._branch = ""
|
|
102
|
+
self._session_id = ""
|
|
103
|
+
|
|
104
|
+
def set_state(self, cwd: str, branch: str, session_id: str) -> None:
|
|
105
|
+
self._cwd, self._branch, self._session_id = cwd, branch, session_id
|
|
106
|
+
self._repaint()
|
|
107
|
+
|
|
108
|
+
def on_resize(self, event: Resize) -> None:
|
|
109
|
+
self._repaint()
|
|
110
|
+
|
|
111
|
+
def _repaint(self) -> None:
|
|
112
|
+
# Branch and session id are short and the most useful thing to keep
|
|
113
|
+
# legible -- only the (already-abbreviated) cwd gets truncated
|
|
114
|
+
# further when space is tight, rather than right-cropping the whole
|
|
115
|
+
# joined line and risking the session id being the part that's lost.
|
|
116
|
+
suffix_bits = [self._branch] if self._branch else []
|
|
117
|
+
suffix_bits.append(self._session_id)
|
|
118
|
+
suffix = " · " + " · ".join(suffix_bits)
|
|
119
|
+
cwd = format.abbrev_cwd(self._cwd)
|
|
120
|
+
width = self.content_size.width
|
|
121
|
+
if width:
|
|
122
|
+
cwd = format.truncate_right(cwd, max(0, width - len(self._PREFIX) - len(suffix)))
|
|
123
|
+
# Even an unabbreviated branch/session id alone can outrun a very
|
|
124
|
+
# narrow pane -- a last-resort truncation of the whole tail keeps
|
|
125
|
+
# this row guaranteed to fit rather than leaning on the engine's
|
|
126
|
+
# own (ellipsis-less) overflow clipping.
|
|
127
|
+
tail = format.truncate_right(f"{cwd}{suffix}", width - len(self._PREFIX))
|
|
128
|
+
else:
|
|
129
|
+
tail = f"{cwd}{suffix}"
|
|
130
|
+
rule = "─" * max(1, width or 40)
|
|
131
|
+
self.update(f"[bold]⌘ omega[/bold] [dim]{format.esc(tail)}[/dim]\n[$rule]{rule}[/$rule]")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class PromptInput(TextArea):
|
|
135
|
+
"""The `#prompt` composer. A real multiline editor (Textual `TextArea`)
|
|
136
|
+
so you can see which line you're on, insert newlines, and paste a block
|
|
137
|
+
without it collapsing to one line.
|
|
138
|
+
|
|
139
|
+
Submit is Enter (empty-line-at-end also works via the usual "send").
|
|
140
|
+
Shift+Enter / Alt+Enter / Ctrl+J insert a newline. Escape clears only
|
|
141
|
+
when idle. Up/down walk input history when the caret is on the first/
|
|
142
|
+
last line; otherwise they move the caret.
|
|
143
|
+
|
|
144
|
+
Word-delete is bound on every key a terminal can actually deliver for it
|
|
145
|
+
-- `ctrl+w`, `alt+backspace` and `ctrl+backspace`. `cmd`-chords are not
|
|
146
|
+
among them: macOS reserves the Command modifier for the window manager.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
BINDINGS = [
|
|
150
|
+
*TextArea.BINDINGS,
|
|
151
|
+
Binding("enter", "submit", show=False, priority=True),
|
|
152
|
+
Binding("shift+enter", "newline", show=False, priority=True),
|
|
153
|
+
Binding("alt+enter", "newline", show=False, priority=True),
|
|
154
|
+
Binding("ctrl+j", "newline", show=False, priority=True),
|
|
155
|
+
Binding("alt+left", "cursor_word_left", show=False),
|
|
156
|
+
Binding("alt+right", "cursor_word_right", show=False),
|
|
157
|
+
Binding("alt+d", "delete_word_right", show=False),
|
|
158
|
+
Binding("escape", "clear_when_idle", show=False, priority=True),
|
|
159
|
+
Binding("up", "history_or_up", show=False, priority=True),
|
|
160
|
+
Binding("down", "history_or_down", show=False, priority=True),
|
|
161
|
+
Binding("tab", "mention_or_tab", show=False, priority=True),
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
165
|
+
placeholder = kwargs.pop("placeholder", "")
|
|
166
|
+
super().__init__(
|
|
167
|
+
"",
|
|
168
|
+
language=None,
|
|
169
|
+
soft_wrap=True,
|
|
170
|
+
tab_behavior="indent",
|
|
171
|
+
show_line_numbers=False,
|
|
172
|
+
compact=True,
|
|
173
|
+
highlight_cursor_line=True,
|
|
174
|
+
placeholder=placeholder,
|
|
175
|
+
**kwargs,
|
|
176
|
+
)
|
|
177
|
+
self._mention_hits: list[str] = []
|
|
178
|
+
self._mention_index = 0
|
|
179
|
+
self._box_min = 3
|
|
180
|
+
self._box_max = 10
|
|
181
|
+
|
|
182
|
+
# Tests and the rest of OmegaApp still talk to `.value` like the old Input.
|
|
183
|
+
@property
|
|
184
|
+
def value(self) -> str:
|
|
185
|
+
return self.text
|
|
186
|
+
|
|
187
|
+
@value.setter
|
|
188
|
+
def value(self, text: str) -> None:
|
|
189
|
+
self.text = text
|
|
190
|
+
self.move_cursor(self.document.end)
|
|
191
|
+
self._sync_box_height()
|
|
192
|
+
|
|
193
|
+
def action_end(self) -> None:
|
|
194
|
+
self.move_cursor(self.document.end)
|
|
195
|
+
|
|
196
|
+
def action_newline(self) -> None:
|
|
197
|
+
self.insert("\n")
|
|
198
|
+
self._sync_box_height()
|
|
199
|
+
|
|
200
|
+
def action_submit(self) -> None:
|
|
201
|
+
if self._try_accept_mention():
|
|
202
|
+
return
|
|
203
|
+
cast("OmegaApp", self.app)._submit_prompt()
|
|
204
|
+
|
|
205
|
+
def action_clear_when_idle(self) -> None:
|
|
206
|
+
if cast("OmegaApp", self.app)._turn_worker is not None:
|
|
207
|
+
return
|
|
208
|
+
if self._mention_hits:
|
|
209
|
+
self._mention_hits = []
|
|
210
|
+
self._mention_index = 0
|
|
211
|
+
cast("OmegaApp", self.app)._refresh_status()
|
|
212
|
+
return
|
|
213
|
+
self.clear()
|
|
214
|
+
|
|
215
|
+
def action_history_or_up(self) -> None:
|
|
216
|
+
if self._mention_hits:
|
|
217
|
+
self._mention_index = (self._mention_index - 1) % len(self._mention_hits)
|
|
218
|
+
cast("OmegaApp", self.app)._refresh_status()
|
|
219
|
+
return
|
|
220
|
+
if self.cursor_at_first_line:
|
|
221
|
+
cast("OmegaApp", self.app).action_history_prev()
|
|
222
|
+
return
|
|
223
|
+
self.action_cursor_up()
|
|
224
|
+
|
|
225
|
+
def action_history_or_down(self) -> None:
|
|
226
|
+
if self._mention_hits:
|
|
227
|
+
self._mention_index = (self._mention_index + 1) % len(self._mention_hits)
|
|
228
|
+
cast("OmegaApp", self.app)._refresh_status()
|
|
229
|
+
return
|
|
230
|
+
if self.cursor_at_last_line:
|
|
231
|
+
cast("OmegaApp", self.app).action_history_next()
|
|
232
|
+
return
|
|
233
|
+
self.action_cursor_down()
|
|
234
|
+
|
|
235
|
+
def action_mention_or_tab(self) -> None:
|
|
236
|
+
if self._try_accept_mention():
|
|
237
|
+
return
|
|
238
|
+
self.insert(" ")
|
|
239
|
+
|
|
240
|
+
def _cursor_index(self) -> int:
|
|
241
|
+
row, col = self.cursor_location
|
|
242
|
+
lines = self.text.split("\n")
|
|
243
|
+
return sum(len(line) + 1 for line in lines[:row]) + col
|
|
244
|
+
|
|
245
|
+
def _move_to_index(self, index: int) -> None:
|
|
246
|
+
text = self.text
|
|
247
|
+
index = max(0, min(index, len(text)))
|
|
248
|
+
row = text.count("\n", 0, index)
|
|
249
|
+
last_nl = text.rfind("\n", 0, index)
|
|
250
|
+
col = index if last_nl < 0 else index - last_nl - 1
|
|
251
|
+
self.move_cursor((row, col))
|
|
252
|
+
|
|
253
|
+
def _try_accept_mention(self) -> bool:
|
|
254
|
+
if not self._mention_hits:
|
|
255
|
+
return False
|
|
256
|
+
path = self._mention_hits[self._mention_index]
|
|
257
|
+
new, cursor = composer_lib.insert_mention(self.text, self._cursor_index(), path)
|
|
258
|
+
self.text = new
|
|
259
|
+
self._move_to_index(cursor)
|
|
260
|
+
self._mention_hits = []
|
|
261
|
+
self._mention_index = 0
|
|
262
|
+
cast("OmegaApp", self.app)._refresh_status()
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
def on_mount(self) -> None:
|
|
266
|
+
self._sync_box_height()
|
|
267
|
+
|
|
268
|
+
def on_text_area_changed(self) -> None:
|
|
269
|
+
self._refresh_mentions()
|
|
270
|
+
self._sync_box_height()
|
|
271
|
+
|
|
272
|
+
def _content_rows(self) -> int:
|
|
273
|
+
text = self.text
|
|
274
|
+
if not text:
|
|
275
|
+
return 1
|
|
276
|
+
rows = text.count("\n") + 1
|
|
277
|
+
width = self.size.width
|
|
278
|
+
if width <= 1:
|
|
279
|
+
return rows
|
|
280
|
+
gutter = 4 if self.show_line_numbers else 0
|
|
281
|
+
usable = max(1, width - gutter)
|
|
282
|
+
extra = 0
|
|
283
|
+
for line in text.split("\n"):
|
|
284
|
+
if len(line) > usable:
|
|
285
|
+
extra += (len(line) - 1) // usable
|
|
286
|
+
return rows + extra
|
|
287
|
+
|
|
288
|
+
def _sync_box_height(self) -> None:
|
|
289
|
+
rows = self._content_rows()
|
|
290
|
+
# Border (2) + content. Stay one line until the user actually wraps.
|
|
291
|
+
height = min(self._box_max, max(self._box_min, rows + 2))
|
|
292
|
+
parent = self.parent
|
|
293
|
+
if parent is not None:
|
|
294
|
+
parent.styles.height = height
|
|
295
|
+
self.show_line_numbers = rows > 1
|
|
296
|
+
|
|
297
|
+
def on_selection_changed(self) -> None:
|
|
298
|
+
self._refresh_mentions()
|
|
299
|
+
|
|
300
|
+
def _refresh_mentions(self) -> None:
|
|
301
|
+
app = cast("OmegaApp", self.app)
|
|
302
|
+
q = composer_lib.mention_query(self.text, self._cursor_index())
|
|
303
|
+
if q is None:
|
|
304
|
+
if self._mention_hits:
|
|
305
|
+
self._mention_hits = []
|
|
306
|
+
self._mention_index = 0
|
|
307
|
+
app._refresh_status()
|
|
308
|
+
return
|
|
309
|
+
hits = composer_lib.rank_files(app.workspace_files, q.query)
|
|
310
|
+
if hits != self._mention_hits:
|
|
311
|
+
self._mention_hits = hits
|
|
312
|
+
self._mention_index = 0
|
|
313
|
+
app._refresh_status()
|
|
314
|
+
|
|
315
|
+
def mention_status(self) -> str:
|
|
316
|
+
if not self._mention_hits:
|
|
317
|
+
return ""
|
|
318
|
+
shown = self._mention_hits[:6]
|
|
319
|
+
i = self._mention_index
|
|
320
|
+
bits = []
|
|
321
|
+
for n, path in enumerate(shown):
|
|
322
|
+
name = path.rsplit("/", 1)[-1]
|
|
323
|
+
bits.append(f"[{n + 1}] {name}" if n == i else name)
|
|
324
|
+
extra = f" +{len(self._mention_hits) - 6}" if len(self._mention_hits) > 6 else ""
|
|
325
|
+
return " @ " + " · ".join(bits) + extra + " tab/enter"
|
|
326
|
+
|
|
327
|
+
async def _on_key(self, event: Key) -> None:
|
|
328
|
+
# TextArea inserts a newline for Enter before bindings run. Steal it.
|
|
329
|
+
if event.key == "enter":
|
|
330
|
+
event.stop()
|
|
331
|
+
event.prevent_default()
|
|
332
|
+
self.action_submit()
|
|
333
|
+
return
|
|
334
|
+
if event.key in ("shift+enter", "alt+enter"):
|
|
335
|
+
event.stop()
|
|
336
|
+
event.prevent_default()
|
|
337
|
+
self.action_newline()
|
|
338
|
+
return
|
|
339
|
+
await super()._on_key(event)
|
|
340
|
+
|
|
341
|
+
async def _on_paste(self, event: Paste) -> None:
|
|
342
|
+
if event.text:
|
|
343
|
+
event.stop()
|
|
344
|
+
event.prevent_default()
|
|
345
|
+
self.insert(event.text)
|
|
346
|
+
return
|
|
347
|
+
await super()._on_paste(event)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class OmegaApp(App[None]):
|
|
351
|
+
# Read by `get_css_variables` from inside `App.__init__`, before this
|
|
352
|
+
# instance has loaded its own preference.
|
|
353
|
+
theme_choice = theme.DEFAULT
|
|
354
|
+
CSS = """
|
|
355
|
+
Screen { layout: vertical; }
|
|
356
|
+
#body { height: 1fr; }
|
|
357
|
+
#main { width: 1fr; height: 1fr; }
|
|
358
|
+
#header { height: 2; }
|
|
359
|
+
#transcript { height: 1fr; padding-top: 1; }
|
|
360
|
+
#input-box { height: 3; min-height: 3; max-height: 10; border: round $rule; padding: 0 1; }
|
|
361
|
+
#input-box:focus-within { border: round $accent; }
|
|
362
|
+
/* A turn in flight: the bracket stays open and typeable, but its border
|
|
363
|
+
says so, and enter holds rather than submits. */
|
|
364
|
+
#input-box.-busy { border: round $warning; }
|
|
365
|
+
#input-box.-busy:focus-within { border: round $warning; }
|
|
366
|
+
#mode-tag { width: auto; padding: 0 1 0 0; height: 1; }
|
|
367
|
+
#prompt { width: 1fr; height: 1fr; background: transparent; border: none; padding: 0; }
|
|
368
|
+
#prompt.-plan-mode { color: $warning; }
|
|
369
|
+
#prompt.-discuss-mode { color: $accent; }
|
|
370
|
+
"""
|
|
371
|
+
BINDINGS = [
|
|
372
|
+
Binding("ctrl+c", "interrupt", "Cancel turn", show=False, priority=True),
|
|
373
|
+
Binding("ctrl+d", "quit_app", "Quit", show=False, priority=True),
|
|
374
|
+
# ctrl+m is not usable here: terminals send the same byte for it as
|
|
375
|
+
# for Enter, so it would fire the input's submit binding instead.
|
|
376
|
+
Binding("ctrl+o", "pick_model", "Model", show=False, priority=True),
|
|
377
|
+
Binding("ctrl+b", "toggle_sidebar", "Sidebar", show=False, priority=True),
|
|
378
|
+
Binding("shift+tab", "cycle_mode", "Cycle mode", show=False, priority=True),
|
|
379
|
+
Binding("ctrl+1", "show_tab(0)", "Session", show=False, priority=True),
|
|
380
|
+
Binding("ctrl+2", "show_tab(1)", "Git", show=False, priority=True),
|
|
381
|
+
Binding("ctrl+3", "show_tab(2)", "Connections", show=False, priority=True),
|
|
382
|
+
Binding("[", "cycle_tab(False)", "Prev tab", show=False),
|
|
383
|
+
Binding("]", "cycle_tab(True)", "Next tab", show=False),
|
|
384
|
+
Binding("g", "expand_latest", "Expand group", show=False),
|
|
385
|
+
Binding("G", "jump_end", "Jump to end", show=False),
|
|
386
|
+
]
|
|
387
|
+
|
|
388
|
+
def __init__(self, cfg: config.Config, sess: session.Session, mode: str,
|
|
389
|
+
history: list[dict[str, Any]], model_alias: str | None = None) -> None:
|
|
390
|
+
super().__init__()
|
|
391
|
+
self.cfg = cfg
|
|
392
|
+
self.sess = sess
|
|
393
|
+
self.mode = mode
|
|
394
|
+
self.history = history
|
|
395
|
+
self.model_alias = model_alias
|
|
396
|
+
self._usage: events.Usage | None = None
|
|
397
|
+
# Cumulative tokens for this session, by model alias (or the raw
|
|
398
|
+
# model id when no alias applies) -- `/cost` sums these against
|
|
399
|
+
# `eval.prices`, unlike `_usage` above which only holds the latest event.
|
|
400
|
+
self._usage_totals: dict[str, dict[str, int]] = {}
|
|
401
|
+
self._last_model: tuple[str | None, str] | None = None
|
|
402
|
+
self._held_notice_shown = False
|
|
403
|
+
self._phase = "idle"
|
|
404
|
+
self._turn_worker: Worker[None] | None = None
|
|
405
|
+
self._input_history: InputHistory | None = None
|
|
406
|
+
self._prefs = prefs.load()
|
|
407
|
+
self._first_launch = not prefs.PATH.exists()
|
|
408
|
+
self._sidebar_visible = bool(self._prefs.get("sidebar", False))
|
|
409
|
+
self.theme_choice = theme.normalize(self._prefs.get("theme"))
|
|
410
|
+
self.register_theme(theme.SYSTEM_THEME)
|
|
411
|
+
self.theme = theme.textual_theme(self.theme_choice)
|
|
412
|
+
self._git_refresh_timer: Timer | None = None
|
|
413
|
+
self.workspace_files: list[str] = []
|
|
414
|
+
|
|
415
|
+
def compose(self) -> ComposeResult:
|
|
416
|
+
with Horizontal(id="body"):
|
|
417
|
+
with Vertical(id="main"):
|
|
418
|
+
yield HeaderBar(id="header")
|
|
419
|
+
yield Transcript(id="transcript")
|
|
420
|
+
yield Sidebar(self.sess, id="sidebar")
|
|
421
|
+
yield StatusBar(id="status")
|
|
422
|
+
with Horizontal(id="input-box"):
|
|
423
|
+
yield Static("", id="mode-tag")
|
|
424
|
+
yield PromptInput(
|
|
425
|
+
id="prompt",
|
|
426
|
+
placeholder="Ask omega… enter send · shift+enter newline · @ file",
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
def on_mount(self) -> None:
|
|
430
|
+
# Deferred import: reads the CURRENT `omega.ui.tui.HISTORY`, so tests
|
|
431
|
+
# that monkeypatch it before construction are respected.
|
|
432
|
+
from . import HISTORY
|
|
433
|
+
self._input_history = InputHistory(HISTORY)
|
|
434
|
+
self._apply_sidebar_visibility()
|
|
435
|
+
self.query_one(Transcript).set_session(self.sess.id)
|
|
436
|
+
self._refresh_status()
|
|
437
|
+
self._apply_mode_style()
|
|
438
|
+
self._refresh_header()
|
|
439
|
+
if self.history:
|
|
440
|
+
self._show_resumed()
|
|
441
|
+
else:
|
|
442
|
+
self.query_one(Transcript).show_empty_state()
|
|
443
|
+
self.set_focus(self.query_one("#prompt", PromptInput))
|
|
444
|
+
self.run_worker(self._index_workspace, exclusive=False, thread=True)
|
|
445
|
+
|
|
446
|
+
def _index_workspace(self) -> None:
|
|
447
|
+
self.workspace_files = composer_lib.list_workspace_files(self.sess.cwd)
|
|
448
|
+
|
|
449
|
+
def on_resize(self, event: Resize) -> None:
|
|
450
|
+
self._apply_sidebar_visibility()
|
|
451
|
+
self._refresh_status()
|
|
452
|
+
|
|
453
|
+
def get_css_variables(self) -> dict[str, str]:
|
|
454
|
+
variables = super().get_css_variables()
|
|
455
|
+
variables["rule"] = theme.rule_color(self.theme_choice)
|
|
456
|
+
return variables
|
|
457
|
+
|
|
458
|
+
def set_theme_choice(self, choice: str) -> None:
|
|
459
|
+
self.theme_choice = theme.normalize(choice)
|
|
460
|
+
self.theme = theme.textual_theme(self.theme_choice)
|
|
461
|
+
self._prefs["theme"] = self.theme_choice
|
|
462
|
+
prefs.save(self._prefs)
|
|
463
|
+
|
|
464
|
+
def _apply_sidebar_visibility(self) -> None:
|
|
465
|
+
"""The panel follows the user's own ctrl+b/`/sidebar` preference, but
|
|
466
|
+
never below `_MIN_WIDTH_FOR_SIDEBAR` total columns -- squeezed into
|
|
467
|
+
whatever's left of a narrow terminal it stops being readable rather
|
|
468
|
+
than useful, so it auto-hides there instead (`StatusBar` surfaces a
|
|
469
|
+
dim hint explaining why, via `_sidebar_auto_hidden`)."""
|
|
470
|
+
self.query_one(Sidebar).display = self._sidebar_visible and self._sidebar_fits()
|
|
471
|
+
|
|
472
|
+
def _sidebar_fits(self) -> bool:
|
|
473
|
+
return self.size.width >= _MIN_WIDTH_FOR_SIDEBAR
|
|
474
|
+
|
|
475
|
+
def _sidebar_auto_hidden(self) -> bool:
|
|
476
|
+
return self._sidebar_visible and not self._sidebar_fits()
|
|
477
|
+
|
|
478
|
+
def _show_resumed(self) -> None:
|
|
479
|
+
hist = self.history
|
|
480
|
+
if hist and str(hist[-1].get("content", "")).startswith(session.RESUME_PREFIX):
|
|
481
|
+
hist = hist[:-1]
|
|
482
|
+
turns = sum(1 for m in hist if m.get("role") == "user")
|
|
483
|
+
ago = format.relative_age(time.time() - self.sess.updated) if self.sess.updated else ""
|
|
484
|
+
self.query_one(Transcript).add_resumed(self.sess.id, turns, len(hist), self.sess.cwd, ago)
|
|
485
|
+
|
|
486
|
+
def _refresh_header(self, *, force: bool = False) -> None:
|
|
487
|
+
self.run_worker(self._load_header(force=force), exclusive=False, thread=False)
|
|
488
|
+
|
|
489
|
+
async def _load_header(self, *, force: bool) -> None:
|
|
490
|
+
cwd = self.sess.cwd
|
|
491
|
+
branch = None if force else _branch_cache.get(cwd)
|
|
492
|
+
if branch is None:
|
|
493
|
+
branch = await _lookup_branch(cwd)
|
|
494
|
+
_branch_cache[cwd] = branch
|
|
495
|
+
self.query_one(HeaderBar).set_state(cwd, branch, self.sess.id)
|
|
496
|
+
|
|
497
|
+
def _role_name(self) -> str:
|
|
498
|
+
return _MODE_ROLE.get(self.mode, "main")
|
|
499
|
+
|
|
500
|
+
def _apply_mode_style(self) -> None:
|
|
501
|
+
prompt = self.query_one("#prompt", PromptInput)
|
|
502
|
+
prompt.set_class(self.mode == "plan", "-plan-mode")
|
|
503
|
+
prompt.set_class(self.mode == "discuss", "-discuss-mode")
|
|
504
|
+
style = _MODE_TAG_STYLE.get(self.mode, "dim")
|
|
505
|
+
self.query_one("#mode-tag", Static).update(f"[{style}]{self.mode}[/{style}] [bold]›[/bold]")
|
|
506
|
+
|
|
507
|
+
def _refresh_status(self) -> None:
|
|
508
|
+
role_name = self._role_name()
|
|
509
|
+
alias, model = self._last_model or (None, "?")
|
|
510
|
+
if self._last_model is None:
|
|
511
|
+
# Before the first ModelUsed event of the session, fall back to a
|
|
512
|
+
# static lookup so the status bar isn't blank on first render.
|
|
513
|
+
try:
|
|
514
|
+
role = self.cfg.model(self.model_alias) if self.model_alias else self.cfg.role(role_name)
|
|
515
|
+
alias, model = role.alias, str(role.model)
|
|
516
|
+
except Exception:
|
|
517
|
+
alias, model = self.model_alias, "?"
|
|
518
|
+
self.query_one(Sidebar).session_tab.record_model(alias, model, limit=self._static_context_limit())
|
|
519
|
+
turns = sum(1 for m in self.history if m.get("role") == "user")
|
|
520
|
+
note = ""
|
|
521
|
+
try:
|
|
522
|
+
note = self.query_one("#prompt", PromptInput).mention_status()
|
|
523
|
+
except Exception:
|
|
524
|
+
note = ""
|
|
525
|
+
state = StatusState(mode=self.mode, role_name=role_name, model=model, alias=alias,
|
|
526
|
+
session_id=self.sess.id, turns=turns, usage=self._usage,
|
|
527
|
+
phase=self._phase, sidebar_auto_hidden=self._sidebar_auto_hidden(),
|
|
528
|
+
note=note)
|
|
529
|
+
self.query_one(StatusBar).set_state(state)
|
|
530
|
+
|
|
531
|
+
def _static_context_limit(self) -> int:
|
|
532
|
+
try:
|
|
533
|
+
return self._context_limit()
|
|
534
|
+
except Exception:
|
|
535
|
+
return 0
|
|
536
|
+
|
|
537
|
+
def _schedule_git_refresh(self) -> None:
|
|
538
|
+
if self._git_refresh_timer is not None:
|
|
539
|
+
self._git_refresh_timer.stop()
|
|
540
|
+
self._git_refresh_timer = self.set_timer(0.3, self._run_git_refresh)
|
|
541
|
+
|
|
542
|
+
def _run_git_refresh(self) -> None:
|
|
543
|
+
self._git_refresh_timer = None
|
|
544
|
+
self.query_one(Sidebar).git_tab.refresh_repos()
|
|
545
|
+
|
|
546
|
+
def emit(self, ev: events.Event) -> None:
|
|
547
|
+
trace.append(self.sess.id, ev, self.sess.turns)
|
|
548
|
+
# A rendering bug (an unescaped model string reaching rich's markup
|
|
549
|
+
# parser, say) must never abort the turn the model is mid-way through
|
|
550
|
+
# -- show one dim marker and keep going instead of propagating. The
|
|
551
|
+
# inner try/except is deliberate: even the marker line's own render
|
|
552
|
+
# must not be able to re-raise and kill the turn a second time.
|
|
553
|
+
try:
|
|
554
|
+
self._dispatch(ev)
|
|
555
|
+
except Exception as e:
|
|
556
|
+
# `add_dim` escapes its own text, so pass the raw message through --
|
|
557
|
+
# escaping it here too would double-escape.
|
|
558
|
+
try:
|
|
559
|
+
self.query_one(Transcript).add_dim(
|
|
560
|
+
f"⚠ render error: {type(e).__name__}: {str(e)[:120]}")
|
|
561
|
+
except Exception:
|
|
562
|
+
pass
|
|
563
|
+
|
|
564
|
+
def _dispatch(self, ev: events.Event) -> None:
|
|
565
|
+
transcript = self.query_one(Transcript)
|
|
566
|
+
sidebar = self.query_one(Sidebar)
|
|
567
|
+
match ev:
|
|
568
|
+
case events.Phase(state=state):
|
|
569
|
+
self._phase = state
|
|
570
|
+
transcript.note_phase(state)
|
|
571
|
+
sidebar.session_tab.note_phase(state)
|
|
572
|
+
self._refresh_status()
|
|
573
|
+
case events.TextDelta(text=text):
|
|
574
|
+
transcript.add_text_delta(text)
|
|
575
|
+
case events.ToolStart():
|
|
576
|
+
transcript.add_tool_start(ev)
|
|
577
|
+
sidebar.session_tab.record_tool_start(ev)
|
|
578
|
+
if ev.name in ("find_tools", "call_tool"):
|
|
579
|
+
sidebar.connections_tab.refresh_status()
|
|
580
|
+
case events.ToolEnd():
|
|
581
|
+
transcript.add_tool_end(ev)
|
|
582
|
+
sidebar.session_tab.record_tool_end(ev)
|
|
583
|
+
if ev.name in ("write", "edit", "bash"):
|
|
584
|
+
self._schedule_git_refresh()
|
|
585
|
+
if ev.name in ("find_tools", "call_tool"):
|
|
586
|
+
sidebar.connections_tab.refresh_status()
|
|
587
|
+
case events.SubagentSpawned():
|
|
588
|
+
transcript.add_subagent_spawned(ev)
|
|
589
|
+
sidebar.session_tab.record_subagent_spawned(ev)
|
|
590
|
+
case events.SubagentDone():
|
|
591
|
+
transcript.add_subagent_done(ev)
|
|
592
|
+
sidebar.session_tab.record_subagent_done(ev)
|
|
593
|
+
case events.Compacted(note=note):
|
|
594
|
+
transcript.add_compacted(ev)
|
|
595
|
+
if not note.startswith("compaction skipped"):
|
|
596
|
+
self.sess.compactions += 1
|
|
597
|
+
case events.MemoryWrite():
|
|
598
|
+
transcript.add_memory_write(ev)
|
|
599
|
+
case events.MemoryConsolidated():
|
|
600
|
+
transcript.add_memory_consolidated(ev)
|
|
601
|
+
case events.Error():
|
|
602
|
+
transcript.add_error(ev)
|
|
603
|
+
case events.Usage():
|
|
604
|
+
self._usage = ev
|
|
605
|
+
self._accumulate_usage(ev)
|
|
606
|
+
sidebar.session_tab.record_usage(ev)
|
|
607
|
+
transcript.note_usage(ev)
|
|
608
|
+
self._refresh_status()
|
|
609
|
+
case events.ModelUsed(alias=alias, model=model):
|
|
610
|
+
self._last_model = (alias, model)
|
|
611
|
+
sidebar.session_tab.record_model(alias, model)
|
|
612
|
+
self._refresh_status()
|
|
613
|
+
case events.Done(text=text):
|
|
614
|
+
transcript.finalize_turn(text)
|
|
615
|
+
if _Checkpoint is not None and isinstance(ev, _Checkpoint):
|
|
616
|
+
transcript.add_checkpoint(ev)
|
|
617
|
+
elif _Verified is not None and isinstance(ev, _Verified):
|
|
618
|
+
transcript.add_verified(ev)
|
|
619
|
+
elif _JobStarted is not None and isinstance(ev, _JobStarted):
|
|
620
|
+
transcript.add_job_started(ev)
|
|
621
|
+
elif _JobFinished is not None and isinstance(ev, _JobFinished):
|
|
622
|
+
transcript.add_job_finished(ev)
|
|
623
|
+
|
|
624
|
+
def _accumulate_usage(self, ev: events.Usage) -> None:
|
|
625
|
+
alias = (self._last_model[0] or self._last_model[1]) if self._last_model else "?"
|
|
626
|
+
totals = self._usage_totals.setdefault(
|
|
627
|
+
alias, {"prompt": 0, "completion": 0, "cache_read": 0, "cache_write": 0})
|
|
628
|
+
totals["prompt"] += ev.prompt_tokens
|
|
629
|
+
totals["completion"] += ev.completion_tokens
|
|
630
|
+
totals["cache_read"] += ev.cache_read
|
|
631
|
+
totals["cache_write"] += ev.cache_write
|
|
632
|
+
|
|
633
|
+
def _cost_text(self) -> str:
|
|
634
|
+
if not self._usage_totals:
|
|
635
|
+
return "no usage yet this session"
|
|
636
|
+
from ...eval import prices
|
|
637
|
+
|
|
638
|
+
lines = []
|
|
639
|
+
grand_total = 0.0
|
|
640
|
+
any_unpriced = False
|
|
641
|
+
for alias, t in sorted(self._usage_totals.items()):
|
|
642
|
+
cost = prices.estimate_cost(alias, t["prompt"], t["completion"])
|
|
643
|
+
if cost is None:
|
|
644
|
+
any_unpriced = True
|
|
645
|
+
cost_text = "unknown"
|
|
646
|
+
else:
|
|
647
|
+
grand_total += cost
|
|
648
|
+
cost_text = f"${cost:.4f}"
|
|
649
|
+
lines.append(f"{alias}: {t['prompt']} in / {t['completion']} out "
|
|
650
|
+
f"(cache {t['cache_read']} read / {t['cache_write']} write) · {cost_text}")
|
|
651
|
+
if len(self._usage_totals) > 1:
|
|
652
|
+
suffix = " + unpriced models" if any_unpriced else ""
|
|
653
|
+
lines.append(f"total: ${grand_total:.4f}{suffix}")
|
|
654
|
+
return "\n".join(lines)
|
|
655
|
+
|
|
656
|
+
def _submit_prompt(self) -> None:
|
|
657
|
+
prompt = self.query_one("#prompt", PromptInput)
|
|
658
|
+
# Mid-turn, enter is a no-op that KEEPS what was typed. The widget
|
|
659
|
+
# used to be disabled outright for the length of a turn, which meant
|
|
660
|
+
# a thought had to be held in the reader's head until the turn ended.
|
|
661
|
+
# Leaving it live lets that thought be written down where it belongs;
|
|
662
|
+
# refusing the submit (rather than queueing or interrupting) keeps the
|
|
663
|
+
# running turn the only thing in flight, so nothing is sent that the
|
|
664
|
+
# turn's own output might have made wrong.
|
|
665
|
+
if self._turn_worker is not None:
|
|
666
|
+
# Once per turn, not once per keypress -- enter pressed five times
|
|
667
|
+
# while waiting should not push five identical notices into the
|
|
668
|
+
# transcript the reader is trying to follow.
|
|
669
|
+
if not self._held_notice_shown:
|
|
670
|
+
self._held_notice_shown = True
|
|
671
|
+
self.query_one(Transcript).add_dim(
|
|
672
|
+
"still working — your prompt is held; ctrl+c cancels the turn")
|
|
673
|
+
return
|
|
674
|
+
text = prompt.value.strip()
|
|
675
|
+
prompt.value = ""
|
|
676
|
+
if not text:
|
|
677
|
+
return
|
|
678
|
+
if self._input_history is not None:
|
|
679
|
+
self._input_history.append(text)
|
|
680
|
+
if text == "?" or text.startswith("/"):
|
|
681
|
+
self._handle_command(text)
|
|
682
|
+
else:
|
|
683
|
+
self._start_turn(text)
|
|
684
|
+
|
|
685
|
+
def _unknown_command(self, text: str) -> str:
|
|
686
|
+
word = text.split()[0] if text.split() else text
|
|
687
|
+
match = difflib.get_close_matches(word, _COMMANDS, n=1)
|
|
688
|
+
suggestion = f" — did you mean {match[0]}?" if match else ""
|
|
689
|
+
return f"unknown command: {text}{suggestion}"
|
|
690
|
+
|
|
691
|
+
def _handle_command(self, text: str) -> None:
|
|
692
|
+
transcript = self.query_one(Transcript)
|
|
693
|
+
if text in ("/plan", "/build", "/discuss"):
|
|
694
|
+
new_mode = text[1:]
|
|
695
|
+
if new_mode == "discuss" and "discuss" not in loop.MODES:
|
|
696
|
+
transcript.add_dim("discuss mode not available in this build")
|
|
697
|
+
return
|
|
698
|
+
self.mode = new_mode
|
|
699
|
+
transcript.add_mode_switch(self.mode)
|
|
700
|
+
self._apply_mode_style()
|
|
701
|
+
self._refresh_status()
|
|
702
|
+
elif text == "/mode":
|
|
703
|
+
transcript.add_dim(f"mode: {self.mode}")
|
|
704
|
+
elif text == "/memory-gc":
|
|
705
|
+
self.run_worker(self._memory_gc(), exclusive=False, thread=False)
|
|
706
|
+
elif text == "/model":
|
|
707
|
+
self.run_worker(self._pick_model(), exclusive=False, thread=False)
|
|
708
|
+
elif text.startswith("/model "):
|
|
709
|
+
self._set_model(text[len("/model "):].strip())
|
|
710
|
+
elif text == "/sidebar":
|
|
711
|
+
self.action_toggle_sidebar()
|
|
712
|
+
elif text == "/theme":
|
|
713
|
+
transcript.add_dim(f"theme: {self.theme_choice} ({' · '.join(theme.CHOICES)})")
|
|
714
|
+
elif text.startswith("/theme "):
|
|
715
|
+
choice = text[len("/theme "):].strip()
|
|
716
|
+
if choice not in theme.CHOICES:
|
|
717
|
+
transcript.add_dim(f"unknown theme: {choice} (want one of {', '.join(theme.CHOICES)})")
|
|
718
|
+
return
|
|
719
|
+
self.set_theme_choice(choice)
|
|
720
|
+
transcript.add_dim(f"theme: {choice}")
|
|
721
|
+
elif text == "/cost":
|
|
722
|
+
transcript.add_dim(self._cost_text())
|
|
723
|
+
elif text == "/export" or text.startswith("/export "):
|
|
724
|
+
path = text[len("/export"):].strip() or None
|
|
725
|
+
out = export.write(self.history, self.sess.id, path)
|
|
726
|
+
transcript.add_dim(f"exported to {out}")
|
|
727
|
+
elif text == "/compact":
|
|
728
|
+
self.run_worker(self._force_compact(), exclusive=False, thread=False)
|
|
729
|
+
elif text == "/undo" or text.startswith("/undo "):
|
|
730
|
+
arg = text[len("/undo"):].strip()
|
|
731
|
+
steps = int(arg) if arg.isdigit() else 1
|
|
732
|
+
self.run_worker(self._undo(steps), exclusive=False, thread=False)
|
|
733
|
+
elif text == "/diff":
|
|
734
|
+
self.run_worker(self._diff(), exclusive=False, thread=False)
|
|
735
|
+
elif text == "/verify":
|
|
736
|
+
self.run_worker(self._verify(), exclusive=False, thread=False)
|
|
737
|
+
elif text == "/sessions":
|
|
738
|
+
self.run_worker(self._pick_session(), exclusive=False, thread=False)
|
|
739
|
+
elif text in ("/help", "?"):
|
|
740
|
+
transcript.add_dim(_HELP_TEXT)
|
|
741
|
+
elif text == "/quit":
|
|
742
|
+
self.action_quit_app()
|
|
743
|
+
else:
|
|
744
|
+
transcript.add_dim(self._unknown_command(text))
|
|
745
|
+
|
|
746
|
+
async def _memory_gc(self) -> None:
|
|
747
|
+
transcript = self.query_one(Transcript)
|
|
748
|
+
transcript.add_dim(await consolidate.run(self.cfg, "project", force=True))
|
|
749
|
+
transcript.add_dim(await consolidate.run(self.cfg, "global", force=True))
|
|
750
|
+
|
|
751
|
+
def _context_limit(self) -> int:
|
|
752
|
+
role = self.cfg.model(self.model_alias) if self.model_alias else self.cfg.role(self._role_name())
|
|
753
|
+
return role.context
|
|
754
|
+
|
|
755
|
+
async def _force_compact(self) -> None:
|
|
756
|
+
transcript = self.query_one(Transcript)
|
|
757
|
+
limit = self._context_limit()
|
|
758
|
+
note = await compact.maybe_compact(self.cfg, self.history, used=limit, limit=limit)
|
|
759
|
+
transcript.add_dim(note or "nothing to compact")
|
|
760
|
+
|
|
761
|
+
async def _undo(self, steps: int) -> None:
|
|
762
|
+
transcript = self.query_one(Transcript)
|
|
763
|
+
try:
|
|
764
|
+
from ... import checkpoint
|
|
765
|
+
except ImportError:
|
|
766
|
+
transcript.add_dim("not available in this build")
|
|
767
|
+
return
|
|
768
|
+
plural = "" if steps == 1 else "s"
|
|
769
|
+
confirmed = await self.push_screen_wait(
|
|
770
|
+
ConfirmScreen("undo", {}, f"revert the last {steps} turn{plural}?"))
|
|
771
|
+
if not confirmed:
|
|
772
|
+
transcript.add_dim("undo cancelled")
|
|
773
|
+
return
|
|
774
|
+
note = await asyncio.to_thread(checkpoint.undo, self.sess.id, steps, cwd=self.sess.cwd)
|
|
775
|
+
transcript.add_dim(note)
|
|
776
|
+
|
|
777
|
+
async def _diff(self) -> None:
|
|
778
|
+
transcript = self.query_one(Transcript)
|
|
779
|
+
try:
|
|
780
|
+
from ... import checkpoint
|
|
781
|
+
except ImportError:
|
|
782
|
+
transcript.add_dim("not available in this build")
|
|
783
|
+
return
|
|
784
|
+
text = await asyncio.to_thread(checkpoint.diff, self.sess.id, cwd=self.sess.cwd)
|
|
785
|
+
await self.push_screen_wait(DiffScreen(None, "checkpoint diff", diff_text=text))
|
|
786
|
+
|
|
787
|
+
async def _verify(self) -> None:
|
|
788
|
+
transcript = self.query_one(Transcript)
|
|
789
|
+
try:
|
|
790
|
+
from ... import verify
|
|
791
|
+
except ImportError:
|
|
792
|
+
transcript.add_dim("not available in this build")
|
|
793
|
+
return
|
|
794
|
+
checks = await asyncio.to_thread(verify.detect, self.sess.cwd)
|
|
795
|
+
if not checks:
|
|
796
|
+
transcript.add_dim("no checks detected for this project")
|
|
797
|
+
return
|
|
798
|
+
results = await asyncio.to_thread(verify.run, checks, self.sess.cwd)
|
|
799
|
+
ok = all(r.ok for r in results)
|
|
800
|
+
summary = ", ".join(f"{r.check.name} {'✓' if r.ok else '✗'}" for r in results)
|
|
801
|
+
transcript.add_dim(("✓ verified: " if ok else "✗ verification failed: ") + summary)
|
|
802
|
+
|
|
803
|
+
async def _pick_session(self) -> None:
|
|
804
|
+
transcript = self.query_one(Transcript)
|
|
805
|
+
rows = [s for s in session.all_sessions() if s.cwd == self.sess.cwd and s.id != self.sess.id][:20]
|
|
806
|
+
if not rows:
|
|
807
|
+
transcript.add_dim("no other sessions for this directory")
|
|
808
|
+
return
|
|
809
|
+
chosen = await self.push_screen_wait(SessionsScreen(rows))
|
|
810
|
+
if chosen:
|
|
811
|
+
self._resume_session(chosen)
|
|
812
|
+
|
|
813
|
+
def _resume_session(self, sid: str) -> None:
|
|
814
|
+
new_sess = session.load(sid)
|
|
815
|
+
self.sess = new_sess
|
|
816
|
+
self.mode = new_sess.mode
|
|
817
|
+
self.history.clear()
|
|
818
|
+
self.history.extend(new_sess.history)
|
|
819
|
+
self.model_alias = new_sess.model_override
|
|
820
|
+
self._usage = None
|
|
821
|
+
self._usage_totals = {}
|
|
822
|
+
self._last_model = None
|
|
823
|
+
transcript = self.query_one(Transcript)
|
|
824
|
+
transcript.set_session(new_sess.id)
|
|
825
|
+
turns = sum(1 for m in self.history if m.get("role") == "user")
|
|
826
|
+
ago = format.relative_age(time.time() - new_sess.updated) if new_sess.updated else ""
|
|
827
|
+
transcript.add_resumed(new_sess.id, turns, len(self.history), new_sess.cwd, ago)
|
|
828
|
+
self.query_one(Sidebar).session_tab.set_session(new_sess)
|
|
829
|
+
self._apply_mode_style()
|
|
830
|
+
self._refresh_status()
|
|
831
|
+
self._refresh_header(force=True)
|
|
832
|
+
|
|
833
|
+
async def _pick_model(self) -> None:
|
|
834
|
+
alias = await self.push_screen_wait(ModelPickerScreen(self.cfg.models, self.model_alias))
|
|
835
|
+
if alias:
|
|
836
|
+
self._set_model(alias)
|
|
837
|
+
|
|
838
|
+
def _set_model(self, arg: str) -> None:
|
|
839
|
+
try:
|
|
840
|
+
alias = self.cfg.resolve_alias(arg)
|
|
841
|
+
except SystemExit as e:
|
|
842
|
+
self.query_one(Transcript).add_dim(str(e))
|
|
843
|
+
return
|
|
844
|
+
self.model_alias = alias
|
|
845
|
+
self.sess.model_override = alias
|
|
846
|
+
role = self.cfg.model(alias)
|
|
847
|
+
self._last_model = (alias, role.model)
|
|
848
|
+
# The stale token/limit pairing belongs to the model being replaced --
|
|
849
|
+
# showing it against the new model's name would misreport its context
|
|
850
|
+
# window until the next Usage event (which may be rounds away).
|
|
851
|
+
self._usage = None
|
|
852
|
+
self.query_one(Transcript).add_dim(f"model: {alias} · {role.model}")
|
|
853
|
+
self._refresh_status()
|
|
854
|
+
|
|
855
|
+
def action_pick_model(self) -> None:
|
|
856
|
+
self.run_worker(self._pick_model(), exclusive=False, thread=False)
|
|
857
|
+
|
|
858
|
+
def action_cycle_mode(self) -> None:
|
|
859
|
+
# A priority binding fires regardless of focus, including inside a
|
|
860
|
+
# modal -- restore its own shift+tab (focus_previous, Screen's
|
|
861
|
+
# default) there instead of silently swallowing the key.
|
|
862
|
+
if isinstance(self.screen, ModalScreen):
|
|
863
|
+
self.screen.focus_previous()
|
|
864
|
+
return
|
|
865
|
+
available = [m for m in _MODE_CYCLE if m != "discuss" or "discuss" in loop.MODES]
|
|
866
|
+
idx = available.index(self.mode) if self.mode in available else -1
|
|
867
|
+
self.mode = available[(idx + 1) % len(available)]
|
|
868
|
+
self.query_one(Transcript).add_mode_switch(self.mode)
|
|
869
|
+
self._apply_mode_style()
|
|
870
|
+
self._refresh_status()
|
|
871
|
+
|
|
872
|
+
def action_toggle_sidebar(self) -> None:
|
|
873
|
+
self._sidebar_visible = not self._sidebar_visible
|
|
874
|
+
self._apply_sidebar_visibility()
|
|
875
|
+
self._prefs["sidebar"] = self._sidebar_visible
|
|
876
|
+
prefs.save(self._prefs)
|
|
877
|
+
self._refresh_status()
|
|
878
|
+
|
|
879
|
+
def action_show_tab(self, index: int) -> None:
|
|
880
|
+
self.query_one(Sidebar).show_tab(index)
|
|
881
|
+
|
|
882
|
+
def action_cycle_tab(self, forward: bool) -> None:
|
|
883
|
+
self.query_one(Sidebar).cycle_tab(forward)
|
|
884
|
+
|
|
885
|
+
def action_expand_latest(self) -> None:
|
|
886
|
+
self.query_one(Transcript).expand_latest()
|
|
887
|
+
|
|
888
|
+
def action_jump_end(self) -> None:
|
|
889
|
+
self.query_one(Transcript).jump_to_end()
|
|
890
|
+
|
|
891
|
+
def _start_turn(self, text: str) -> None:
|
|
892
|
+
self.history.append({"role": "user", "content": text})
|
|
893
|
+
self.query_one(Transcript).add_user_message(text, self.mode)
|
|
894
|
+
self.query_one(Sidebar).session_tab.reset_turn()
|
|
895
|
+
self.query_one("#input-box").add_class("-busy")
|
|
896
|
+
self._held_notice_shown = False
|
|
897
|
+
self._turn_worker = self.run_worker(self._run_turn(), exclusive=True, thread=False)
|
|
898
|
+
|
|
899
|
+
async def _run_turn(self) -> None:
|
|
900
|
+
interrupted = False
|
|
901
|
+
try:
|
|
902
|
+
await loop.run_turn(self.cfg, self.history, mode=self.mode, emit=self.emit,
|
|
903
|
+
model=self.model_alias)
|
|
904
|
+
except asyncio.CancelledError:
|
|
905
|
+
interrupted = True
|
|
906
|
+
except Exception as e:
|
|
907
|
+
self.emit(events.Error(message=f"{type(e).__name__}: {e}"))
|
|
908
|
+
finally:
|
|
909
|
+
try:
|
|
910
|
+
self.sess.close_turn(self.history, self.mode, interrupted)
|
|
911
|
+
except Exception as e:
|
|
912
|
+
self.query_one(Transcript).add_error(events.Error(f"could not save session: {e}"))
|
|
913
|
+
self._turn_worker = None
|
|
914
|
+
self.emit(events.Phase("idle"))
|
|
915
|
+
if self._git_refresh_timer is not None:
|
|
916
|
+
self._git_refresh_timer.stop()
|
|
917
|
+
self._git_refresh_timer = None
|
|
918
|
+
sidebar = self.query_one(Sidebar)
|
|
919
|
+
sidebar.git_tab.refresh_repos()
|
|
920
|
+
sidebar.connections_tab.refresh_status()
|
|
921
|
+
self._refresh_header(force=True)
|
|
922
|
+
self.query_one("#input-box").remove_class("-busy")
|
|
923
|
+
self.set_focus(self.query_one("#prompt", PromptInput))
|
|
924
|
+
|
|
925
|
+
def action_interrupt(self) -> None:
|
|
926
|
+
if self._turn_worker is not None:
|
|
927
|
+
self._turn_worker.cancel()
|
|
928
|
+
|
|
929
|
+
def action_quit_app(self) -> None:
|
|
930
|
+
prompt = self.query_one("#prompt", PromptInput)
|
|
931
|
+
if prompt.value:
|
|
932
|
+
prompt.action_delete_right()
|
|
933
|
+
return
|
|
934
|
+
if self._turn_worker is not None:
|
|
935
|
+
self._turn_worker.cancel()
|
|
936
|
+
self.exit()
|
|
937
|
+
|
|
938
|
+
def action_history_prev(self) -> None:
|
|
939
|
+
self._cycle_history(forward=False)
|
|
940
|
+
|
|
941
|
+
def action_history_next(self) -> None:
|
|
942
|
+
self._cycle_history(forward=True)
|
|
943
|
+
|
|
944
|
+
def _cycle_history(self, forward: bool) -> None:
|
|
945
|
+
if self._input_history is None:
|
|
946
|
+
return
|
|
947
|
+
prompt = self.query_one("#prompt", PromptInput)
|
|
948
|
+
value = (self._input_history.next(prompt.value) if forward
|
|
949
|
+
else self._input_history.prev(prompt.value))
|
|
950
|
+
if value is not None:
|
|
951
|
+
prompt.value = value
|
|
952
|
+
prompt.action_end()
|
|
953
|
+
|
|
954
|
+
async def confirm(self, name: str, args: dict[str, Any], why: str) -> bool:
|
|
955
|
+
return await self.push_screen_wait(ConfirmScreen(name, args, why))
|
|
956
|
+
|
|
957
|
+
async def ask_user(self, question: str, options: list[events.Option], multi_select: bool) -> str:
|
|
958
|
+
return await self.push_screen_wait(AskUserScreen(question, options, multi_select))
|