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/format.py ADDED
@@ -0,0 +1,708 @@
1
+ """Pure text formatting shared by both UIs: the dim one-liners for
2
+ `ToolStart`/`ToolEnd`/`SubagentSpawned`/`SubagentDone`/`Compacted`/
3
+ `MemoryWrite`/`MemoryConsolidated`/`Error`, the per-tool call/outcome
4
+ descriptions, path shortening, and the category color palette. Shared here so
5
+ `ui/plain.py` and `ui/tui/transcript.py` cannot drift on wording or color.
6
+ Callers own presentation concerns these functions don't: `ui/plain.py`'s
7
+ leading blank-line spacing is added at the call site, not here.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import subprocess
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from rich.markup import escape
18
+
19
+ from .. import events
20
+
21
+ # A plain assignment, not `import escape as esc` -- mypy strict's
22
+ # no-implicit-reexport rule hides a renamed import from other modules unless
23
+ # it's re-exported via `__all__`, but a module-level name binding like this
24
+ # one is an ordinary public attribute. `format.esc` is used by every module
25
+ # that renders model/tool/user-derived text into rich markup (`transcript.py`,
26
+ # `sidebar.py`, `plain.py`) -- a literal "[" in a bash command, a commit
27
+ # subject, or a provider error message must never be parsed as a style tag
28
+ # (that raised `MarkupError` and aborted the turn before this was added).
29
+ esc = escape
30
+
31
+ # ---- paths ---------------------------------------------------------------
32
+
33
+
34
+ # Worktrees of the cwd's repo, cached for the life of the process (like
35
+ # `app.py`'s `_branch_cache`) -- `git worktree list` only ever changes between
36
+ # turns, so re-running it on every path we render would be wasteful. Keyed by
37
+ # the cwd it was discovered from, sorted longest-path-first so a nested match
38
+ # resolves to the most specific worktree.
39
+ _worktree_cache: dict[str, list[tuple[Path, str]]] = {}
40
+
41
+
42
+ def _discover_worktrees(cwd: str) -> list[tuple[Path, str]]:
43
+ if cwd in _worktree_cache:
44
+ return _worktree_cache[cwd]
45
+ found: list[tuple[Path, str]] = []
46
+ try:
47
+ proc = subprocess.run(
48
+ ["git", "worktree", "list", "--porcelain"],
49
+ cwd=cwd, capture_output=True, text=True, timeout=2)
50
+ if proc.returncode == 0:
51
+ for line in proc.stdout.splitlines():
52
+ if line.startswith("worktree "):
53
+ wt_path = Path(line[len("worktree "):]).resolve()
54
+ found.append((wt_path, wt_path.name))
55
+ except (OSError, subprocess.SubprocessError):
56
+ pass
57
+ found.sort(key=lambda t: len(str(t[0])), reverse=True)
58
+ _worktree_cache[cwd] = found
59
+ return found
60
+
61
+
62
+ def _worktree_relpath(abs_path: Path) -> str | None:
63
+ """`⎇ <worktree-name>/<path-within-worktree>` when `abs_path` lives inside
64
+ one of the cwd repo's git worktrees, else `None`."""
65
+ for wt_path, name in _discover_worktrees(os.getcwd()):
66
+ try:
67
+ rel = abs_path.relative_to(wt_path)
68
+ except ValueError:
69
+ continue
70
+ return f"⎇ {name}" if str(rel) == "." else f"⎇ {name}/{rel}"
71
+ return None
72
+
73
+
74
+ def _tail_segments(path: str, segments: int = 3) -> str:
75
+ parts = [seg for seg in path.split("/") if seg]
76
+ if len(parts) <= segments:
77
+ return "/".join(parts) if parts else path
78
+ return ".../" + "/".join(parts[-segments:])
79
+
80
+
81
+ def relpath(path: str) -> str:
82
+ """`path` relative to the cwd; when it's outside the cwd tree, rendered as
83
+ `⎇ <worktree-name>/<path-within-worktree>` if it lives in a sibling git
84
+ worktree of the cwd's repo, else abbreviated to its last 3 segments -- an
85
+ absolute path is mostly noise once you know where you are, and a bare
86
+ `~`-abbreviated absolute path was unreadable for a worktree checkout many
87
+ directories deep."""
88
+ if not path:
89
+ return path
90
+ try:
91
+ p = Path(path).expanduser()
92
+ abs_p = p if p.is_absolute() else Path(os.getcwd()) / p
93
+ rel = os.path.relpath(abs_p, os.getcwd())
94
+ if not rel.startswith(".."):
95
+ return rel
96
+ except (ValueError, OSError):
97
+ return path
98
+ worktree = _worktree_relpath(abs_p)
99
+ if worktree is not None:
100
+ return worktree
101
+ return _tail_segments(str(abs_p))
102
+
103
+
104
+ def _truncate(text: Any, limit: int = 60) -> str:
105
+ flat = " ".join(str(text).split())
106
+ return flat if len(flat) <= limit else flat[:limit - 1] + "…"
107
+
108
+
109
+ def _q(text: Any, limit: int = 60) -> str:
110
+ return f"'{_truncate(text, limit)}'"
111
+
112
+
113
+ def truncate_middle(text: str, width: int) -> str:
114
+ """Cut from the middle rather than the end -- a path or command that
115
+ overruns the terminal keeps both its recognizable start and its
116
+ (usually more specific) tail instead of losing the tail to a fixed cutoff."""
117
+ if width <= 0 or len(text) <= width:
118
+ return text
119
+ if width <= 1:
120
+ return text[:width]
121
+ keep = width - 1
122
+ left = (keep + 1) // 2
123
+ right = keep - left
124
+ return text[:left] + "…" + (text[-right:] if right else "")
125
+
126
+
127
+ def truncate_right(text: str, width: int) -> str:
128
+ """Cut from the end, keeping the recognizable start -- for chrome lines
129
+ (header, status) where the most useful information (cwd, mode) comes
130
+ first and a lost tail is more acceptable than a lost head."""
131
+ if width <= 0:
132
+ return ""
133
+ if len(text) <= width:
134
+ return text
135
+ if width == 1:
136
+ return "…"
137
+ return text[: width - 1] + "…"
138
+
139
+
140
+ def fmt_num(n: float) -> str:
141
+ """`13600 -> '13.6k'`, `1_000_000 -> '1.0M'`, `42 -> '42'` -- one shared
142
+ scale so chars/tokens/counts never disagree on how big "big" is."""
143
+ n = float(n)
144
+ sign = "-" if n < 0 else ""
145
+ n = abs(n)
146
+ if n >= 1_000_000:
147
+ return f"{sign}{n / 1_000_000:.1f}M"
148
+ if n >= 1_000:
149
+ return f"{sign}{n / 1_000:.1f}k"
150
+ return f"{sign}{int(n)}"
151
+
152
+
153
+ _TAG_RE = re.compile(r"\[/?[a-zA-Z0-9_ $#,.\-]*\]")
154
+
155
+
156
+ def visible_len(markup: str) -> int:
157
+ """Length of `markup` with `[style]...[/style]` tags removed -- good
158
+ enough for column math since every glyph this UI prints is single-width."""
159
+ return len(_TAG_RE.sub("", markup))
160
+
161
+
162
+ def right_align(left: str, right: str, width: int) -> str:
163
+ """`left`, padded with spaces so `right` lands at column `width`. Falls
164
+ back to a single space when the two would collide in a narrow terminal."""
165
+ gap = width - visible_len(left) - visible_len(right)
166
+ if gap < 1:
167
+ return f"{left} {right}"
168
+ return f"{left}{' ' * gap}{right}"
169
+
170
+
171
+ def _without_name(name: str, preview: str) -> str:
172
+ """`describe_call`'s output always leads with the tool name (sidebar.py's
173
+ path/server extraction depends on that), but the TUI renders the name
174
+ separately in bold -- repeating it verbatim on the same line reads as a
175
+ typo, not emphasis, so strip it back off for display."""
176
+ if preview == name:
177
+ return ""
178
+ prefix = name + " "
179
+ return preview[len(prefix):] if preview.startswith(prefix) else preview
180
+
181
+
182
+ def pad_name(name: str, width: int = 12) -> str:
183
+ """Left-justify `name` to `width` columns (never truncated) so a run of
184
+ tool lines lines up its detail text in one column."""
185
+ return f"{name:<{width}}" if len(name) < width else name + " "
186
+
187
+
188
+ def abbrev_cwd(path: str, segments: int = 3) -> str:
189
+ """`~`-abbreviated, trimmed to its last `segments` path components -- the
190
+ header bar has one line to spend on a cwd that can otherwise be very
191
+ long."""
192
+ home = str(Path.home())
193
+ if path == home or path.startswith(home + "/"):
194
+ rest = path[len(home):].lstrip("/")
195
+ parts = rest.split("/") if rest else []
196
+ if len(parts) > segments:
197
+ return "~/…/" + "/".join(parts[-segments:])
198
+ return "~/" + "/".join(parts) if parts else "~"
199
+ parts = [p for p in path.split("/") if p]
200
+ if len(parts) > segments:
201
+ return ".../" + "/".join(parts[-segments:])
202
+ return path
203
+
204
+
205
+ def relative_age(seconds: float) -> str:
206
+ """`3661 -> '1h'` -- coarse, human "how long ago" for a timestamp diff."""
207
+ seconds = max(0.0, seconds)
208
+ if seconds < 60:
209
+ return f"{int(seconds)}s"
210
+ minutes = seconds / 60
211
+ if minutes < 60:
212
+ return f"{int(minutes)}m"
213
+ hours = minutes / 60
214
+ if hours < 24:
215
+ return f"{int(hours)}h"
216
+ return f"{int(hours / 24)}d"
217
+
218
+
219
+ # ---- category colors -------------------------------------------------------
220
+
221
+ _MEMORY_TOOLS = {"remember", "recall", "supersede", "link"}
222
+ _ARTIFACT_TOOLS = {"fetch_result", "list_artifacts", "save_artifact", "update_artifact"}
223
+ _MCP_TOOLS = {"call_tool", "find_tools"}
224
+
225
+ STYLE = {
226
+ "read": "cyan", "write": "yellow", "bash": "magenta", "subagent": "green",
227
+ "memory": "blue", "artifact": "dim cyan", "ask_user": "bold yellow",
228
+ "mcp": "bright_blue", "error": "red", "outcome": "dim",
229
+ }
230
+
231
+
232
+ def category(name: str) -> str:
233
+ if name in ("read", "glob", "grep"):
234
+ return "read"
235
+ if name in ("write", "edit"):
236
+ return "write"
237
+ if name == "bash":
238
+ return "bash"
239
+ if name == "subagent":
240
+ return "subagent"
241
+ if name in _MEMORY_TOOLS:
242
+ return "memory"
243
+ if name in _ARTIFACT_TOOLS:
244
+ return "artifact"
245
+ if name == "ask_user":
246
+ return "ask_user"
247
+ if name in _MCP_TOOLS or name.startswith("mcp__"):
248
+ return "mcp"
249
+ return "read"
250
+
251
+
252
+ def style_for(name: str) -> str:
253
+ return STYLE.get(category(name), "white")
254
+
255
+
256
+ # One mark for every call, coloured by category. Shape carries the row's
257
+ # STATE (ran / failed) and colour carries its kind -- splitting the two that
258
+ # way keeps a burst of rows reading as one column of calls rather than a row
259
+ # of unrelated symbols, and leaves `✕` unambiguous when it appears.
260
+ CALL_GLYPH = "⏺"
261
+ ERROR_GLYPH = "✕"
262
+
263
+
264
+ def glyph_for(name: str, *, failed: bool = False) -> str:
265
+ """The row's gutter mark. A failed call keeps its category colour but
266
+ swaps to `✕` -- failure is the one thing that must be readable from
267
+ across the room, and shape carries further than a red that a dim
268
+ terminal theme may barely differentiate from the surrounding text."""
269
+ return ERROR_GLYPH if failed else CALL_GLYPH
270
+
271
+
272
+ def display_name(name: str) -> str:
273
+ """`call_tool` -> `CallTool`. A capitalised name reads as the subject of
274
+ the row rather than as part of the argument text beside it, which a
275
+ lowercase name padded into a fixed column never quite did."""
276
+ return "".join(part.capitalize() for part in name.split("_"))
277
+
278
+
279
+ # ---- per-tool call descriptions (C9a) --------------------------------------
280
+
281
+ # Matches the `cd <dir> && ` a model prepends to keep a worktree's shell state
282
+ # -- `describe_call` collapses this boilerplate down to `(in ⎇ name)` when
283
+ # `<dir>` is a git worktree, so a burst of commands in the same worktree reads
284
+ # as the actual command instead of a repeated absolute path.
285
+ _BASH_CD_PREFIX_RE = re.compile(r"^cd\s+(\S+)\s+&&\s+")
286
+
287
+ # Generous next to the one-line budget `tool_start` then applies -- the cap
288
+ # here only stops a pathological heredoc from reaching the renderer, while
289
+ # how much actually fits is decided against the real pane width downstream.
290
+ _BASH_PREVIEW_CHARS = 400
291
+
292
+
293
+ def _cd_tag(target: Path) -> str:
294
+ """A short name for a `cd`-ed directory: nothing when it is just the cwd,
295
+ the worktree marker when it is one, else the last path segment."""
296
+ worktree = _worktree_relpath(target)
297
+ if worktree is not None:
298
+ return worktree
299
+ try:
300
+ if target.resolve() == Path(os.getcwd()).resolve():
301
+ return ""
302
+ except OSError:
303
+ pass
304
+ return target.name or str(target)
305
+
306
+
307
+ def _bash_command(raw: str) -> str:
308
+ """The command, with the model's `cd <path> && ` preamble moved to a
309
+ trailing `(in <dir>)` tag.
310
+
311
+ Models prefix nearly every call with a `cd` to pin the shell's cwd. Left
312
+ in place it spends most of the row on an absolute path the reader already
313
+ knows -- and since the row is then cut to fit, the cut lands exactly where
314
+ the command starts, which is the only part that differs between two rows.
315
+ Leading with the command puts the distinguishing text where the eye
316
+ already is."""
317
+ command = raw.replace("\n", "⏎")
318
+ match = _BASH_CD_PREFIX_RE.match(command)
319
+ if match is None:
320
+ return _truncate(command, _BASH_PREVIEW_CHARS)
321
+ target = Path(match.group(1)).expanduser()
322
+ if not target.is_absolute():
323
+ target = Path(os.getcwd()) / target
324
+ rest = _truncate(command[match.end():], _BASH_PREVIEW_CHARS)
325
+ tag = _cd_tag(target)
326
+ return f"{rest} (in {tag})" if tag else rest
327
+
328
+
329
+ def describe_call(name: str, args: dict[str, Any]) -> str:
330
+ """What a tool call is actually doing, for `ToolStart.args_preview` --
331
+ shared so plain and the TUI never disagree on the wording."""
332
+ get = args.get
333
+ if name == "read":
334
+ path = relpath(str(get("path") or ""))
335
+ offset, limit = get("offset") or 0, get("limit")
336
+ suffix = f":{offset}+{limit}" if offset or (limit and limit != 2000) else ""
337
+ return f"read {path}{suffix}"
338
+ if name == "grep":
339
+ pattern = _truncate(get("pattern") or "")
340
+ path = relpath(str(get("path") or "."))
341
+ glob = get("glob") or "*"
342
+ return f"grep /{pattern}/ in {path} ({glob})"
343
+ if name == "glob":
344
+ pattern = _truncate(get("pattern") or "")
345
+ path = relpath(str(get("path") or "."))
346
+ return f"glob {pattern} in {path}"
347
+ if name == "bash":
348
+ return f"bash $ {_bash_command(str(get('command') or ''))}"
349
+ if name == "write":
350
+ content = get("content") or ""
351
+ return f"write {relpath(str(get('path') or ''))} ({len(content)} chars)"
352
+ if name == "edit":
353
+ return f"edit {relpath(str(get('path') or ''))}"
354
+ if name == "fetch_result":
355
+ offset, limit = get("offset") or 0, get("limit") or 4000
356
+ return f"fetch_result artifact {get('id') or ''} @{offset}+{limit}"
357
+ if name == "list_artifacts":
358
+ return "list_artifacts"
359
+ if name == "save_artifact":
360
+ return f"save_artifact {_q(get('title') or '')}"
361
+ if name == "update_artifact":
362
+ return f"update_artifact artifact {get('id') or ''}"
363
+ if name == "recall":
364
+ return f"recall {_q(get('query') or '')} {get('scope') or 'both'}"
365
+ if name == "remember":
366
+ return f"remember {_q(get('title') or '')} {get('type') or 'fact'}/{get('scope') or 'project'}"
367
+ if name == "supersede":
368
+ return f"supersede {_q(get('old') or '')}"
369
+ if name == "link":
370
+ return f"link {get('a') or ''} → {get('b') or ''} ({get('relation') or ''})"
371
+ if name == "subagent":
372
+ tier = get("tier") or "fast"
373
+ return f"subagent({tier}) {_truncate(get('task') or '')}"
374
+ if name == "ask_user":
375
+ return f"ask_user {_q(get('question') or '')}"
376
+ if name == "find_tools":
377
+ return f"find_tools {_q(get('query') or '')}"
378
+ if name == "call_tool":
379
+ tool_name = str(get("name") or "")
380
+ parts = tool_name.split("__")
381
+ label = f"{parts[1]}:{parts[2]}" if len(parts) >= 3 and tool_name.startswith("mcp__") else tool_name
382
+ kwargs = get("arguments") or {}
383
+ items = list(kwargs.items())
384
+ kv = ", ".join(f"{k}={v}" for k, v in items[:4])
385
+ more = ", …" if len(items) > 4 else ""
386
+ return f"call_tool {label}({kv}{more})"
387
+ detail = str(get("path") or get("pattern") or get("command") or get("task")
388
+ or get("query") or "")
389
+ return f"{name} {_truncate(detail)}" if detail else name
390
+
391
+
392
+ # ---- outcome folding (C7b/C9a) ---------------------------------------------
393
+
394
+ _EXIT_RE = re.compile(r"\[exit (\d+)\]")
395
+ _FULL_CHARS_RE = re.compile(r"\[full output: (\d+) chars")
396
+ # Strips the `<untrusted source="...">...</untrusted>` wrapper (mcp.py) from
397
+ # an error PREVIEW only -- the model still sees the full wrapped text, this
398
+ # just keeps the wrapper's own markup out of the human-facing "→ error: …"
399
+ # line, which was otherwise eating most of an already-short truncation budget.
400
+ _UNTRUSTED_WRAP_RE = re.compile(r'<untrusted source="[^"]*">\s*|\s*</untrusted>\s*$')
401
+
402
+
403
+ _WROTE_RE = re.compile(r"wrote (\d+) lines")
404
+
405
+
406
+ def _plural(n: int, noun: str) -> str:
407
+ return f"{n} {noun}" if n == 1 else f"{n} {noun}s"
408
+
409
+
410
+ def _written_summary(text: str) -> str:
411
+ m = _WROTE_RE.search(text)
412
+ return f"Wrote {_plural(int(m.group(1)), 'line')}" if m else "Wrote file"
413
+
414
+
415
+ def diff_counts(text: str) -> tuple[int, int]:
416
+ """`(additions, removals)` in a unified diff, ignoring its `---`/`+++`
417
+ file headers -- which start with the same characters as the content lines
418
+ and would otherwise each count as a change."""
419
+ added = removed = 0
420
+ for line in text.splitlines():
421
+ if line.startswith("+++") or line.startswith("---"):
422
+ continue
423
+ if line.startswith("+"):
424
+ added += 1
425
+ elif line.startswith("-"):
426
+ removed += 1
427
+ return added, removed
428
+
429
+
430
+ def diff_style(line: str) -> str:
431
+ """The colour for one line of a rendered diff. Only ever applied to a
432
+ tool whose result IS a diff -- a grep hit beginning with `-` is ordinary
433
+ text, and colouring it as a removal would be a lie about the file."""
434
+ if line.startswith("+"):
435
+ return "green"
436
+ if line.startswith("-"):
437
+ return "red"
438
+ if line.startswith("@@"):
439
+ return "cyan"
440
+ return "dim"
441
+
442
+
443
+ # Tools whose result is a unified diff, so its lines take diff colouring.
444
+ DIFF_RESULT_TOOLS = {"edit"}
445
+
446
+
447
+ def result_char_count(text: str, offloaded: bool) -> int:
448
+ """The FULL result length, not the (possibly truncated) preview stored in
449
+ `ToolEnd.result_preview` -- an offloaded result states its true length in
450
+ its own footer, so parse that instead of measuring the shortened text."""
451
+ if offloaded:
452
+ m = _FULL_CHARS_RE.search(text)
453
+ if m:
454
+ return int(m.group(1))
455
+ return len(text)
456
+
457
+
458
+ def describe_outcome(name: str, text: str, duration_s: float, offloaded: bool,
459
+ artifact_id: str | None, result_chars: int) -> str:
460
+ """The `→ …` suffix folded onto a tool's line on `ToolEnd`. Empty string
461
+ means nothing worth showing (a fast, ordinary, non-offloaded call)."""
462
+ stripped = text.strip()
463
+ if stripped.startswith("error:"):
464
+ detail = _UNTRUSTED_WRAP_RE.sub("", stripped[len('error:'):].strip()).strip()
465
+ # Truncated at the offload threshold, not the ~200 chars the TUI's
466
+ # collapsed row actually shows -- the extra length is what makes the
467
+ # row's expand-on-enter affordance have something real to reveal.
468
+ return f"→ error: {_truncate(detail, 4000)}"
469
+
470
+ parts: list[str] = []
471
+ no_hits = stripped in ("(no matches)", "")
472
+ if name == "read":
473
+ parts.append(f"Read {len(text.splitlines())} lines")
474
+ elif name == "grep":
475
+ parts.append("No matches" if no_hits else f"Found {len(text.splitlines())} matches")
476
+ elif name == "glob":
477
+ parts.append("No files" if no_hits else f"Found {len(text.splitlines())} files")
478
+ elif name == "edit":
479
+ added, removed = diff_counts(text)
480
+ parts.append(f"Updated with {_plural(added, 'addition')} "
481
+ f"and {_plural(removed, 'removal')}")
482
+ elif name == "write":
483
+ parts.append(_written_summary(stripped))
484
+ elif name == "bash":
485
+ m = _EXIT_RE.search(text)
486
+ if m and m.group(1) != "0":
487
+ parts.append(f"Exit {m.group(1)}")
488
+ elif name == "find_tools":
489
+ if stripped.startswith("no tools matched") or not stripped:
490
+ parts.append("No match")
491
+ else:
492
+ count = stripped.count("\n\n") + 1
493
+ parts.append(f"Found {count} tool{'' if count == 1 else 's'}")
494
+ elif name == "recall":
495
+ if stripped == "(no matching memories)" or not stripped:
496
+ parts.append("None")
497
+ else:
498
+ count = stripped.count("\n\n") + 1
499
+ parts.append(f"Found {count} memor{'y' if count == 1 else 'ies'}")
500
+ elif name == "call_tool" and not offloaded:
501
+ parts.append(f"{fmt_num(result_chars)} chars")
502
+
503
+ if duration_s >= 1:
504
+ parts.append(f"{duration_s:.1f}s")
505
+ if offloaded:
506
+ parts.append(f"{fmt_num(result_chars)} chars · artifact {artifact_id}")
507
+
508
+ return "→ " + " · ".join(parts) if parts else ""
509
+
510
+
511
+ # ---- result previews --------------------------------------------------------
512
+
513
+ # How many lines of a tool's OWN output are worth showing under its row
514
+ # before the reader is better served by expanding it. Budgets differ because
515
+ # the tools differ: a bash run's first lines are usually the whole answer, a
516
+ # grep's are the matches themselves, while `read`'s are the top of a file the
517
+ # reader already chose and can see in their editor -- its row's path and line
518
+ # count say everything a preview would.
519
+ PREVIEW_BUDGET = {
520
+ "bash": 6, "grep": 5, "glob": 5, "call_tool": 5,
521
+ "recall": 3, "find_tools": 3,
522
+ "edit": 12, # the diff -- the whole point of showing the row
523
+ "read": 0, "write": 0,
524
+ }
525
+ PREVIEW_DEFAULT = 3
526
+ # What an expanded block shows -- bounded, because `result_preview` can hold
527
+ # several thousand characters and a block that outgrows the pane is no more
528
+ # readable than one that shows nothing.
529
+ PREVIEW_EXPANDED = 40
530
+
531
+ # Footers the harness itself appends to a result. They are already stated in
532
+ # the outcome line (`exit 1`, `12.4k chars · artifact a3`), so repeating them
533
+ # inside the preview spends the budget restating what is one line below.
534
+ _FOOTER_RE = re.compile(r"^\s*\[(?:exit \d+|full output:.*|truncated.*|stderr)\]\s*$")
535
+ _NOISE_PREVIEWS = {"(no matches)", "(no output, exit 0)", "(no matching memories)"}
536
+
537
+
538
+ def preview_lines(name: str, text: str, *, limit: int | None = None,
539
+ width: int | None = None) -> list[str]:
540
+ """The first few real lines of a tool's own output, for display beneath
541
+ its call row.
542
+
543
+ A count alone (`60 lines`, `12 matches`) says a call happened but not
544
+ what it found, which is the one thing the reader is actually watching
545
+ for. This returns the evidence itself -- already trimmed of the harness's
546
+ own footers, blank runs, and anything past `width` -- so the common case
547
+ needs no expansion at all."""
548
+ if limit is None:
549
+ limit = PREVIEW_BUDGET.get(name, PREVIEW_DEFAULT)
550
+ if limit <= 0:
551
+ return []
552
+ stripped = text.strip()
553
+ if not stripped or stripped in _NOISE_PREVIEWS or stripped.startswith("error:"):
554
+ return []
555
+ # A diff result opens with the tool's own `edited <path>` acknowledgement,
556
+ # which the call row above already says. Keeping only real diff lines
557
+ # drops it without having to special-case its wording.
558
+ is_diff = name in DIFF_RESULT_TOOLS
559
+ out: list[str] = []
560
+ for raw in stripped.splitlines():
561
+ line = raw.rstrip()
562
+ if not line.strip() or _FOOTER_RE.match(line):
563
+ continue
564
+ if is_diff and not line.startswith(("+", "-", "@@", " ")):
565
+ continue
566
+ # Tabs render at whatever width the terminal chose, which breaks the
567
+ # rail's alignment; expand them here so every preview line starts
568
+ # exactly where the one above it did.
569
+ line = line.expandtabs(4)
570
+ if width is not None:
571
+ line = truncate_right(line, width)
572
+ out.append(line)
573
+ if len(out) >= limit:
574
+ break
575
+ return out
576
+
577
+
578
+ def preview_hidden(name: str, text: str, shown: int) -> int:
579
+ """How many more non-empty lines the full result holds beyond `shown` --
580
+ the number the collapsed block advertises as worth expanding for."""
581
+ stripped = text.strip()
582
+ if not stripped or stripped in _NOISE_PREVIEWS:
583
+ return 0
584
+ total = sum(1 for line in stripped.splitlines()
585
+ if line.strip() and not _FOOTER_RE.match(line.rstrip()))
586
+ return max(0, total - shown)
587
+
588
+
589
+ # ---- transcript one-liners --------------------------------------------------
590
+
591
+ # A bash command is the one detail worth more than a single row: it is what
592
+ # the reader is deciding about, and cutting it to one line reliably cuts it
593
+ # mid-path. Three lines' worth of budget, still middle-truncated so both the
594
+ # command's head and its (usually more specific) tail survive.
595
+ _WIDTH_BUDGET = {"bash": 3}
596
+
597
+
598
+ def call_args(name: str, args_preview: str) -> str:
599
+ """The parenthesised argument text for a call row. `describe_call` writes
600
+ for a padded-column layout -- double spaces separating fields, a leading
601
+ `$` marking bash's shell text -- both of which are chrome that `Name(...)`
602
+ supplies on its own."""
603
+ detail = _without_name(name, args_preview).strip()
604
+ if name == "bash":
605
+ detail = detail.removeprefix("$ ")
606
+ return re.sub(r"\s{2,}", " ", detail)
607
+
608
+
609
+ def tool_start(ev: events.ToolStart, *, show_subagent_suffix: bool = True,
610
+ width: int | None = None, failed: bool = False) -> str:
611
+ style = style_for(ev.name)
612
+ detail = call_args(ev.name, ev.args_preview)
613
+ label = display_name(ev.name)
614
+ if width is not None:
615
+ width *= _WIDTH_BUDGET.get(ev.name, 1)
616
+ # The name and its brackets are chrome the argument text must fit
617
+ # around, not extra room it can spend.
618
+ width = max(8, width - len(label) - 2)
619
+ if ev.subagent_id:
620
+ # The tier/id suffix is appended AFTER truncation, not before it --
621
+ # its own length must come out of `width`'s budget first, or a
622
+ # subagent row with the suffix shown overflows past the caller's
623
+ # intended width by however many columns the suffix takes.
624
+ suffix = f" ({esc(ev.tier or '')}·{esc(ev.subagent_id)})" if show_subagent_suffix else ""
625
+ if width is not None:
626
+ detail = truncate_middle(detail, max(0, width - len(suffix)))
627
+ detail = esc(detail)
628
+ # `└` stays: under a subagent it marks NESTING, which the call glyph
629
+ # does not replace -- the glyph follows it so a nested row still says
630
+ # both where it sits and what it is.
631
+ mark = glyph_for(ev.name, failed=failed)
632
+ return (f" [dim]└ {mark} [{style}]{label}[/{style}]"
633
+ f"({detail}){suffix}[/dim]")
634
+ if width is not None:
635
+ detail = truncate_middle(detail, width)
636
+ detail = esc(detail)
637
+ mark = glyph_for(ev.name, failed=failed)
638
+ mark_style = "bold red" if failed else style
639
+ return (f"[{mark_style}]{mark}[/{mark_style}] [bold {style}]{label}[/bold {style}]"
640
+ f"[dim]([/dim]{detail}[dim])[/dim]")
641
+
642
+
643
+ def tool_end(ev: events.ToolEnd) -> str | None:
644
+ if ev.offloaded:
645
+ text = ev.outcome or f"offloaded → artifact {ev.artifact_id}"
646
+ return f" [dim]↳ {esc(text)}[/dim]"
647
+ if ev.outcome.startswith("→ error") or (ev.name == "bash" and "exit" in ev.outcome):
648
+ return f" [dim]{esc(ev.outcome)}[/dim]"
649
+ if ev.name in ("find_tools", "call_tool", "recall") and ev.outcome:
650
+ return f" [dim]{esc(ev.outcome)}[/dim]"
651
+ return None
652
+
653
+
654
+ def subagent_spawned(ev: events.SubagentSpawned, *, show_id: bool = False) -> str:
655
+ id_suffix = f" [dim]{esc(ev.subagent_id)}[/dim]" if show_id else ""
656
+ return (f"[green]●[/green] [bold green]subagent[/bold green]([green]{esc(ev.tier)}[/green]) "
657
+ f"{esc(ev.task_preview)}{id_suffix}")
658
+
659
+
660
+ def subagent_done(ev: events.SubagentDone, task_preview: str = "", elapsed_s: float = 0.0) -> str:
661
+ task = esc(task_preview or ev.subagent_id)
662
+ return f'[green]●[/green] Agent [bold]"{task}"[/bold] finished · {elapsed_s:.0f}s'
663
+
664
+
665
+ def compacted(ev: events.Compacted) -> str:
666
+ return f"[dim]⏺ {esc(ev.note)}[/dim]"
667
+
668
+
669
+ def memory_write(ev: events.MemoryWrite) -> str:
670
+ return (f" [blue]◆[/blue] [dim]memory: {esc(ev.type)} '{esc(ev.title)}' "
671
+ f"({esc(ev.scope)})[/dim]")
672
+
673
+
674
+ def memory_consolidated(ev: events.MemoryConsolidated) -> str:
675
+ return f" [blue]◆[/blue] [dim]memory: {esc(ev.summary)}[/dim]"
676
+
677
+
678
+ def error(ev: events.Error) -> str:
679
+ first_line = ev.message.splitlines()[0] if ev.message else ""
680
+ return f"[red]✗[/red] error {esc(first_line)}"
681
+
682
+
683
+ # ---- B1's edit-safety events (Checkpoint/Verified/JobStarted/JobFinished) --
684
+ # These dataclasses live in `events.py`, which B1 owns and may not have
685
+ # landed yet -- callers duck-type on the attributes below (matching the
686
+ # signatures agreed in the plan) rather than importing the classes.
687
+
688
+
689
+ def checkpoint(ev: Any) -> str:
690
+ return "[dim]⎘ checkpoint[/dim]"
691
+
692
+
693
+ def verified(ev: Any) -> str:
694
+ summary = esc(str(ev.results_summary))
695
+ if ev.ok:
696
+ return f"[dim]✓ verified: {summary}[/dim]"
697
+ return f"[red]✗ verification failed: {summary}[/red]"
698
+
699
+
700
+ def job_started(ev: Any) -> str:
701
+ return f"[dim]⟳ job {esc(str(ev.id))} started[/dim]"
702
+
703
+
704
+ def job_finished(ev: Any) -> str:
705
+ job_id = esc(str(ev.id))
706
+ if ev.exit_code == 0:
707
+ return f"[dim]✓ job {job_id} finished (exit 0)[/dim]"
708
+ return f"[dim]✗ job {job_id} finished (exit {ev.exit_code})[/dim]"