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
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))