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/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]"
|