loom-code 0.1.1__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.
- loom_code/__init__.py +22 -0
- loom_code/_post_commit.py +119 -0
- loom_code/agent.py +544 -0
- loom_code/approval.py +616 -0
- loom_code/browse/__init__.py +291 -0
- loom_code/browse/act.py +467 -0
- loom_code/browse/observe.py +249 -0
- loom_code/browse/session.py +96 -0
- loom_code/browse/verify.py +194 -0
- loom_code/checkpoint.py +283 -0
- loom_code/cli.py +495 -0
- loom_code/code_index.py +703 -0
- loom_code/compact.py +143 -0
- loom_code/consent.py +47 -0
- loom_code/credentials.py +527 -0
- loom_code/edit_tool.py +635 -0
- loom_code/extensions.py +522 -0
- loom_code/file_history.py +322 -0
- loom_code/file_tools.py +93 -0
- loom_code/git_hook.py +200 -0
- loom_code/grep_tool.py +430 -0
- loom_code/hooks.py +297 -0
- loom_code/loominit/__init__.py +23 -0
- loom_code/loominit/_ast_walk.py +429 -0
- loom_code/loominit/_files.py +284 -0
- loom_code/loominit/_graph.py +141 -0
- loom_code/loominit/_resolve.py +392 -0
- loom_code/loominit/_tests_map.py +108 -0
- loom_code/loominit/extractor.py +332 -0
- loom_code/loominit/repomap.py +225 -0
- loom_code/loominit/schema.py +242 -0
- loom_code/lsp_tools.py +396 -0
- loom_code/mcp_host.py +79 -0
- loom_code/operator.py +449 -0
- loom_code/paste.py +97 -0
- loom_code/paths.py +52 -0
- loom_code/permissions.py +177 -0
- loom_code/project.py +104 -0
- loom_code/prompts.py +451 -0
- loom_code/render.py +783 -0
- loom_code/repl.py +4080 -0
- loom_code/rules.py +267 -0
- loom_code/sandboxed_bash.py +176 -0
- loom_code/scribe.py +88 -0
- loom_code/skills/__init__.py +16 -0
- loom_code/skills/graphify/SKILL.md +97 -0
- loom_code/skills/graphify/tools.py +570 -0
- loom_code/trust.py +216 -0
- loom_code/turn.py +169 -0
- loom_code/web_fetch.py +370 -0
- loom_code/workers.py +758 -0
- loom_code/worktree.py +134 -0
- loom_code-0.1.1.dist-info/METADATA +224 -0
- loom_code-0.1.1.dist-info/RECORD +58 -0
- loom_code-0.1.1.dist-info/WHEEL +5 -0
- loom_code-0.1.1.dist-info/entry_points.txt +2 -0
- loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
- loom_code-0.1.1.dist-info/top_level.txt +1 -0
loom_code/render.py
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
"""Render loomflow ``Event``s to the terminal with ``rich``.
|
|
2
|
+
|
|
3
|
+
``Agent.stream()`` yields ``Event`` objects — ``model_chunk``,
|
|
4
|
+
``tool_call``, ``tool_result``, ``permission_ask``, ``completed``,
|
|
5
|
+
``error``, etc. This module turns that event stream into the
|
|
6
|
+
live terminal UI. It is PURELY presentation — no agent logic.
|
|
7
|
+
|
|
8
|
+
Event payloads are plain dicts; we ``.get()`` everything
|
|
9
|
+
defensively so a payload-shape change in loomflow degrades to a
|
|
10
|
+
slightly-uglier line instead of a crash.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.syntax import Syntax
|
|
20
|
+
from rich.text import Text
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
# Tools whose results are worth showing in full-ish; others get a
|
|
25
|
+
# one-line summary so the terminal doesn't flood. We cap BOTH char
|
|
26
|
+
# and line count — a single long line (jq output, minified JSON,
|
|
27
|
+
# a big SQL row) blows past the char cap with no newlines, and a
|
|
28
|
+
# multi-line directory listing exceeds the line cap before chars.
|
|
29
|
+
# Truncate on whichever hits first; the trailer says how much was
|
|
30
|
+
# elided in BOTH dimensions so the user knows the scale.
|
|
31
|
+
_VERBOSE_RESULT_TOOLS = {"read", "grep", "ls", "find"}
|
|
32
|
+
_RESULT_PREVIEW_CHARS = 300
|
|
33
|
+
_RESULT_PREVIEW_LINES = 8
|
|
34
|
+
# Verbose tools (read/grep/ls/find) get this multiplier — they
|
|
35
|
+
# legitimately produce more useful long output.
|
|
36
|
+
_VERBOSE_MULTIPLIER = 3
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _truncate_preview(
|
|
40
|
+
text: str, *, char_cap: int, line_cap: int
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Cap a tool-result preview at BOTH a character count AND a
|
|
43
|
+
line count — whichever hits first. Returns the truncated text
|
|
44
|
+
with a trailer naming what was elided.
|
|
45
|
+
|
|
46
|
+
Char cap alone is wrong: a one-line minified JSON blob hides
|
|
47
|
+
far less terminal real estate than 20 lines of grep output at
|
|
48
|
+
the same character count. Line cap alone is wrong: a single
|
|
49
|
+
unwrapped line can be thousands of characters. Use both."""
|
|
50
|
+
if not text:
|
|
51
|
+
return ""
|
|
52
|
+
lines = text.splitlines()
|
|
53
|
+
n_lines = len(lines)
|
|
54
|
+
n_chars = len(text)
|
|
55
|
+
# Decide which cap is more restrictive for this output.
|
|
56
|
+
line_truncated = n_lines > line_cap
|
|
57
|
+
char_truncated = n_chars > char_cap
|
|
58
|
+
if not line_truncated and not char_truncated:
|
|
59
|
+
return text
|
|
60
|
+
# Take the smaller of (line_cap lines, char_cap chars).
|
|
61
|
+
by_lines = "\n".join(lines[:line_cap])
|
|
62
|
+
capped = by_lines[:char_cap] if len(by_lines) > char_cap else by_lines
|
|
63
|
+
elided_lines = n_lines - capped.count("\n") - 1
|
|
64
|
+
elided_chars = n_chars - len(capped)
|
|
65
|
+
parts = []
|
|
66
|
+
if elided_lines > 0:
|
|
67
|
+
parts.append(f"+{elided_lines} lines")
|
|
68
|
+
if elided_chars > 0:
|
|
69
|
+
parts.append(f"+{elided_chars} chars")
|
|
70
|
+
trailer = ", ".join(parts) if parts else "truncated"
|
|
71
|
+
return f"{capped}\n … ({trailer})"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _status_label_for(tool: str, args: dict[str, Any]) -> str:
|
|
75
|
+
"""Pick a human spinner label for an in-flight tool call.
|
|
76
|
+
|
|
77
|
+
delegate gets the worker name (loom-code's main workflow shape
|
|
78
|
+
so it's worth a custom label); bash gets a short clip of the
|
|
79
|
+
actual command; web_search shows the query; everything else
|
|
80
|
+
falls back to ``running <tool>...``."""
|
|
81
|
+
if tool == "delegate":
|
|
82
|
+
target = str(args.get("target") or args.get("agent") or "?")
|
|
83
|
+
return f"delegating to {target}..."
|
|
84
|
+
if tool == "bash":
|
|
85
|
+
cmd = str(args.get("command") or "").strip().splitlines()[0:1]
|
|
86
|
+
first = cmd[0] if cmd else ""
|
|
87
|
+
clip = first[:40] + ("…" if len(first) > 40 else "")
|
|
88
|
+
return f"running: {clip}" if clip else "running bash..."
|
|
89
|
+
if tool == "web_search":
|
|
90
|
+
q = str(args.get("query") or "").strip()
|
|
91
|
+
return f"searching: {q[:40]}..." if q else "searching the web..."
|
|
92
|
+
return f"running {tool}..."
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class StreamRenderer:
|
|
96
|
+
"""Stateful renderer for one ``Agent.stream`` run.
|
|
97
|
+
|
|
98
|
+
Tracks whether we're mid-model-chunk so tool calls don't
|
|
99
|
+
interleave awkwardly with streamed assistant text. Also
|
|
100
|
+
captures the last living-plan render + the final result dict
|
|
101
|
+
so the REPL can power ``/plan`` and ``/cost`` without
|
|
102
|
+
re-parsing the stream.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
set_status: Callable[[str], None] | None = None,
|
|
109
|
+
pause_status: Callable[[], None] | None = None,
|
|
110
|
+
sandbox: bool = False,
|
|
111
|
+
gate_active: Callable[[], bool] | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""``set_status(label)`` / ``pause_status()`` are optional
|
|
114
|
+
callbacks the REPL wires to a Rich ``console.status``
|
|
115
|
+
spinner. The renderer pauses the spinner while assistant
|
|
116
|
+
prose is streaming (it would corrupt the same line) and
|
|
117
|
+
sets a new label on each tool boundary so the user has
|
|
118
|
+
something to read between events. Both default to no-op so
|
|
119
|
+
non-REPL callers (one-shot CLI) keep working unchanged.
|
|
120
|
+
|
|
121
|
+
``sandbox`` adds a 🔒 marker to each ``bash`` tool line so the
|
|
122
|
+
user can see — per command, not just at launch — that the
|
|
123
|
+
coder's shell is kernel-isolated. Off by default."""
|
|
124
|
+
self._sandbox = sandbox
|
|
125
|
+
# While this returns True (approval prompt on screen), events
|
|
126
|
+
# queue in _deferred instead of printing — see handle().
|
|
127
|
+
self._gate_active = gate_active
|
|
128
|
+
self._deferred: list[Any] = []
|
|
129
|
+
self._in_text = False
|
|
130
|
+
# Mid-thinking-burst flag (reasoning chunks stream dim).
|
|
131
|
+
self._in_thinking = False
|
|
132
|
+
# Whether ANY assistant prose has been shown this run — drives
|
|
133
|
+
# the _on_completed fallback (print result["output"] only if
|
|
134
|
+
# nothing streamed, so we never double-print).
|
|
135
|
+
self._any_text = False
|
|
136
|
+
# Buffer for one prose burst — chunks accumulate here while
|
|
137
|
+
# they're also streamed inline as plain text (see
|
|
138
|
+
# ``_on_model_chunk`` for why we DON'T use a Rich Live
|
|
139
|
+
# widget: it recurses against the REPL's status spinner).
|
|
140
|
+
# The buffer lets ``_on_completed`` know whether anything
|
|
141
|
+
# streamed (so it doesn't double-print result["output"]).
|
|
142
|
+
self._text_buffer: list[str] = []
|
|
143
|
+
# call_id -> tool name. loomflow's `tool_result` event only
|
|
144
|
+
# carries `call_id` (ToolResult has no `tool` field) — the
|
|
145
|
+
# tool NAME is only on the `tool_call` event. We bridge the
|
|
146
|
+
# two so result rendering can tell which tool ran.
|
|
147
|
+
self._call_names: dict[str, str] = {}
|
|
148
|
+
# Spinner callbacks (see __init__ docstring). Default to
|
|
149
|
+
# no-ops so all the event handlers can call them unconditionally.
|
|
150
|
+
self._set_status: Callable[[str], None] = (
|
|
151
|
+
set_status if set_status is not None else (lambda _t: None)
|
|
152
|
+
)
|
|
153
|
+
self._pause_status: Callable[[], None] = (
|
|
154
|
+
pause_status if pause_status is not None else (lambda: None)
|
|
155
|
+
)
|
|
156
|
+
# REPL-readable after the stream drains:
|
|
157
|
+
self.last_plan: str | None = None
|
|
158
|
+
# Structured plan steps captured from the most recent
|
|
159
|
+
# ``plan_write`` tool_call args. Used by the auto-continue
|
|
160
|
+
# logic in :mod:`repl` — a list of {description, status,
|
|
161
|
+
# finding} dicts is more reliable than regex-parsing the
|
|
162
|
+
# rendered markdown for progress (the rendering can change;
|
|
163
|
+
# the tool args shape is loomflow's stable API).
|
|
164
|
+
self.last_plan_steps: list[dict[str, Any]] | None = None
|
|
165
|
+
self.last_result: dict[str, Any] | None = None
|
|
166
|
+
# Captured during the run so cli.py can build the end-of-run
|
|
167
|
+
# summary (files changed / verified / notes captured) without
|
|
168
|
+
# the agent having to report any of it itself.
|
|
169
|
+
self.bash_commands: list[str] = []
|
|
170
|
+
self.notes_written: list[tuple[str, str]] = [] # (kind, title)
|
|
171
|
+
# Files this run WROTE to — captured from edit/multi_edit/write
|
|
172
|
+
# tool calls (all use the ``path`` arg). Feeds file-touch
|
|
173
|
+
# history (loom_code.file_history): "last time you touched X,
|
|
174
|
+
# the change was marked bad." Read-only tools (read/grep) are
|
|
175
|
+
# NOT touches — only mutations land here.
|
|
176
|
+
self.files_touched: list[str] = []
|
|
177
|
+
|
|
178
|
+
def handle(self, event: Any) -> None:
|
|
179
|
+
"""Render a single ``Event``. ``kind`` is a string enum;
|
|
180
|
+
compare against the lowercase values loomflow uses.
|
|
181
|
+
|
|
182
|
+
While the approval gate is prompting (``gate_active``), events
|
|
183
|
+
are DEFERRED instead of rendered: the selector redraws itself
|
|
184
|
+
in place with cursor-up escapes, so any concurrent print
|
|
185
|
+
displaces its geometry and the menu visibly duplicates
|
|
186
|
+
(observed live). Deferred events flush in order the moment the
|
|
187
|
+
gate closes."""
|
|
188
|
+
if self._gate_active is not None and self._gate_active():
|
|
189
|
+
self._deferred.append(event)
|
|
190
|
+
return
|
|
191
|
+
if self._deferred:
|
|
192
|
+
pending, self._deferred = self._deferred, []
|
|
193
|
+
for ev in pending:
|
|
194
|
+
self._dispatch(ev)
|
|
195
|
+
self._dispatch(event)
|
|
196
|
+
|
|
197
|
+
def _dispatch(self, event: Any) -> None:
|
|
198
|
+
kind = str(event.kind)
|
|
199
|
+
payload = event.payload or {}
|
|
200
|
+
method = getattr(self, f"_on_{kind}", None)
|
|
201
|
+
if method is not None:
|
|
202
|
+
method(payload)
|
|
203
|
+
# Unknown event kinds are silently ignored — forward-compat
|
|
204
|
+
# with new loomflow event types.
|
|
205
|
+
|
|
206
|
+
def flush_deferred(self) -> None:
|
|
207
|
+
"""Render anything still queued from a gate window — called by
|
|
208
|
+
the REPL after the stream ends so no event is ever lost."""
|
|
209
|
+
pending, self._deferred = self._deferred, []
|
|
210
|
+
for ev in pending:
|
|
211
|
+
self._dispatch(ev)
|
|
212
|
+
|
|
213
|
+
# ---- text streaming -------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def _on_model_chunk(self, payload: dict[str, Any]) -> None:
|
|
216
|
+
# loomflow shape: payload = {"chunk": ModelChunk.model_dump()}
|
|
217
|
+
# where ModelChunk is discriminated by ``kind`` — only the
|
|
218
|
+
# "text" kind carries assistant prose; "tool_call"/"finish"
|
|
219
|
+
# chunks have text=None and are surfaced by other handlers.
|
|
220
|
+
#
|
|
221
|
+
# We stream the chunks as PLAIN TEXT inline, NOT through a
|
|
222
|
+
# Rich ``Live`` widget. Hard-won reason: a Live nested
|
|
223
|
+
# inside the REPL's ``console.status()`` spinner (which is
|
|
224
|
+
# ALSO a Live) makes Rich's refresh recurse on
|
|
225
|
+
# ``console._live_stack[0].refresh()`` — observed blowing
|
|
226
|
+
# the stack (RecursionError, ~992 frames) AND corrupting
|
|
227
|
+
# the approval prompt's stdin. Two Live contexts on one
|
|
228
|
+
# console don't compose. Plain ``console.print(..., end="")``
|
|
229
|
+
# gives the same token-by-token streaming feel with zero
|
|
230
|
+
# Live machinery, so it can't deadlock or recurse.
|
|
231
|
+
#
|
|
232
|
+
# Tradeoff: streamed prose isn't markdown-rendered (you see
|
|
233
|
+
# literal ``**bold**`` / code fences). Acceptable — a crash
|
|
234
|
+
# that blocks the agent mid-write is infinitely worse than
|
|
235
|
+
# un-prettified streaming.
|
|
236
|
+
chunk = payload.get("chunk") or {}
|
|
237
|
+
kind = chunk.get("kind")
|
|
238
|
+
if kind == "thinking":
|
|
239
|
+
# Reasoning stream (Claude extended thinking / o-series
|
|
240
|
+
# summaries when /effort is set). Shown dim so the user
|
|
241
|
+
# sees the model IS working — previously these chunks
|
|
242
|
+
# were silently dropped and high-effort turns looked
|
|
243
|
+
# stalled. Not buffered: thinking is not the answer.
|
|
244
|
+
text = chunk.get("text") or ""
|
|
245
|
+
if not text:
|
|
246
|
+
return
|
|
247
|
+
if not self._in_thinking:
|
|
248
|
+
self._pause_status()
|
|
249
|
+
console.print()
|
|
250
|
+
console.print(
|
|
251
|
+
" ✻ thinking… ", end="", style="dim italic"
|
|
252
|
+
)
|
|
253
|
+
self._in_thinking = True
|
|
254
|
+
console.print(
|
|
255
|
+
text,
|
|
256
|
+
end="",
|
|
257
|
+
markup=False,
|
|
258
|
+
highlight=False,
|
|
259
|
+
style="dim",
|
|
260
|
+
)
|
|
261
|
+
return
|
|
262
|
+
if kind != "text":
|
|
263
|
+
return
|
|
264
|
+
text = chunk.get("text") or ""
|
|
265
|
+
if not text:
|
|
266
|
+
return
|
|
267
|
+
self._end_thinking()
|
|
268
|
+
if not self._in_text:
|
|
269
|
+
# First chunk of the message — keep the spinner up
|
|
270
|
+
# ("responding…") and BUFFER instead of printing raw. We
|
|
271
|
+
# render the whole thing as clean Markdown on completion
|
|
272
|
+
# (Claude-Code style), which is why we don't stream the raw
|
|
273
|
+
# tokens: showing raw ``###``/``**`` then replacing them
|
|
274
|
+
# would need fragile cursor-erase math. The spinner gives
|
|
275
|
+
# the "working" feedback in the meantime.
|
|
276
|
+
self._set_status("responding…")
|
|
277
|
+
self._in_text = True
|
|
278
|
+
self._any_text = True
|
|
279
|
+
self._text_buffer.append(text)
|
|
280
|
+
|
|
281
|
+
def _end_thinking(self) -> None:
|
|
282
|
+
"""Close an open thinking burst (newline + spinner back)."""
|
|
283
|
+
if not self._in_thinking:
|
|
284
|
+
return
|
|
285
|
+
self._in_thinking = False
|
|
286
|
+
console.print()
|
|
287
|
+
self._set_status("thinking...")
|
|
288
|
+
|
|
289
|
+
def _end_text(self) -> None:
|
|
290
|
+
self._end_thinking()
|
|
291
|
+
if not self._in_text:
|
|
292
|
+
return
|
|
293
|
+
# Message complete. It was BUFFERED (not streamed raw), so now
|
|
294
|
+
# print it ONCE — as rendered Markdown when it looks markdown-y
|
|
295
|
+
# (headings, bold, code fences → Claude-Code look), else as
|
|
296
|
+
# plain text. The spinner was paused by the caller/_end path.
|
|
297
|
+
full = "".join(self._text_buffer)
|
|
298
|
+
self._text_buffer.clear()
|
|
299
|
+
self._in_text = False
|
|
300
|
+
self._pause_status()
|
|
301
|
+
console.print() # blank line before the answer
|
|
302
|
+
if full.strip():
|
|
303
|
+
# A labelled marker before the response, so the model's
|
|
304
|
+
# output is visually attributed + separated from the tool
|
|
305
|
+
# activity above it (Claude-Code ``● Assistant`` style).
|
|
306
|
+
console.print(Text("● loom", style="bold cyan"))
|
|
307
|
+
rendered = self._render_markdown(full)
|
|
308
|
+
if rendered is not None:
|
|
309
|
+
console.print(rendered)
|
|
310
|
+
elif full.strip():
|
|
311
|
+
# Plain text (no markdown to gain) — markup/highlight OFF so
|
|
312
|
+
# a stray ``[`` in the model's text isn't parsed as a tag.
|
|
313
|
+
console.print(full, markup=False, highlight=False)
|
|
314
|
+
# Deliberately DON'T restart the spinner here. A real next event
|
|
315
|
+
# (tool_call / architecture line) sets its own status; the turn
|
|
316
|
+
# end pauses it. Restarting to "thinking..." on every prose
|
|
317
|
+
# burst rendered a spinner line that Rich then cleared, leaving
|
|
318
|
+
# the dead gap between the answer and the turn-summary rule.
|
|
319
|
+
|
|
320
|
+
@staticmethod
|
|
321
|
+
def _render_markdown(text: str) -> Any:
|
|
322
|
+
"""Render ``text`` as Rich Markdown, or None to print it plain.
|
|
323
|
+
Skips empty output and text with no markdown to gain, and never
|
|
324
|
+
raises — a parse failure falls back to the plain print."""
|
|
325
|
+
if not text.strip():
|
|
326
|
+
return None
|
|
327
|
+
if not any(
|
|
328
|
+
m in text for m in ("#", "```", "**", "- ", "* ", "|", "`")
|
|
329
|
+
):
|
|
330
|
+
return None
|
|
331
|
+
try:
|
|
332
|
+
from rich.markdown import Markdown
|
|
333
|
+
|
|
334
|
+
return Markdown(text, code_theme="ansi_dark")
|
|
335
|
+
except Exception: # noqa: BLE001 — render must never crash
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
# ---- architecture events --------------------------------------------
|
|
339
|
+
|
|
340
|
+
def _on_architecture_event(self, payload: dict[str, Any]) -> None:
|
|
341
|
+
"""Surface the bits the user actually cares about. Most
|
|
342
|
+
architecture events are framework-internal progress signals
|
|
343
|
+
the renderer correctly hides — the exceptions:
|
|
344
|
+
|
|
345
|
+
* ``router.dispatched`` — WHICH route the classifier picked
|
|
346
|
+
('simple' vs 'complex'), fixing the observability asymmetry
|
|
347
|
+
where COMPLEX turns showed delegate+worker activity but
|
|
348
|
+
SIMPLE turns showed nothing but a spinner.
|
|
349
|
+
* ``auto_compacted`` — the conversation was just compacted;
|
|
350
|
+
the user should know why the model "forgot" verbatim detail.
|
|
351
|
+
* ``stop_hook.fired`` — an auto-continue iteration started
|
|
352
|
+
(the plan still has open steps), so a long turn visibly
|
|
353
|
+
progresses instead of looking stuck.
|
|
354
|
+
|
|
355
|
+
NOTE: keep this the ONLY definition of this method — a
|
|
356
|
+
previous refactor left two copies and the second silently
|
|
357
|
+
shadowed the first, hiding every one of these lines.
|
|
358
|
+
"""
|
|
359
|
+
name = payload.get("name") or ""
|
|
360
|
+
if name == "router.dispatched":
|
|
361
|
+
route = payload.get("route")
|
|
362
|
+
if not route:
|
|
363
|
+
return
|
|
364
|
+
# End any in-flight prose burst first so the route line
|
|
365
|
+
# doesn't land mid-Live-render. Then print a single line.
|
|
366
|
+
self._end_text()
|
|
367
|
+
self._pause_status()
|
|
368
|
+
console.print(
|
|
369
|
+
f" [dim]→ routed to[/dim] [cyan]{route}[/cyan]"
|
|
370
|
+
)
|
|
371
|
+
# Re-set the status spinner so the user has feedback while
|
|
372
|
+
# the specialist starts up. Specific tool labels will
|
|
373
|
+
# overwrite it as the route's agent does its work.
|
|
374
|
+
self._set_status(f"{route} working...")
|
|
375
|
+
elif name == "auto_compacted":
|
|
376
|
+
self._end_text()
|
|
377
|
+
dropped = payload.get("messages_dropped")
|
|
378
|
+
extra = f" ({dropped} messages summarised)" if dropped else ""
|
|
379
|
+
console.print(
|
|
380
|
+
Text(f" ✦ context compacted{extra}", style="dim magenta")
|
|
381
|
+
)
|
|
382
|
+
elif name == "stop_hook.fired":
|
|
383
|
+
self._end_text()
|
|
384
|
+
iteration = payload.get("iteration")
|
|
385
|
+
tag = f" ({iteration})" if iteration else ""
|
|
386
|
+
console.print(
|
|
387
|
+
Text(
|
|
388
|
+
f" ▸ auto-continue{tag} — plan has open steps",
|
|
389
|
+
style="dim",
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
else:
|
|
393
|
+
# Generic living-plan / architecture progress
|
|
394
|
+
# (``plan.updated``, ``self_refine.critique``, …). The
|
|
395
|
+
# previous handler surfaced any 'plan'-ish event as a dim
|
|
396
|
+
# ▸ line; keep that so a long multi-step turn shows
|
|
397
|
+
# movement instead of a silent spinner. Some architectures
|
|
398
|
+
# key the label under ``event`` rather than ``name``.
|
|
399
|
+
label = str(name or payload.get("event") or "")
|
|
400
|
+
if "plan" in label.lower():
|
|
401
|
+
self._end_text()
|
|
402
|
+
console.print(Text(f" ▸ {label}", style="dim magenta"))
|
|
403
|
+
|
|
404
|
+
# ---- tools ----------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
def _on_tool_call(self, payload: dict[str, Any]) -> None:
|
|
407
|
+
self._end_text()
|
|
408
|
+
call = payload.get("call") or {}
|
|
409
|
+
tool = call.get("tool", "?")
|
|
410
|
+
# Remember id -> name so _on_tool_result can name the tool.
|
|
411
|
+
call_id = call.get("id")
|
|
412
|
+
if call_id:
|
|
413
|
+
self._call_names[str(call_id)] = str(tool)
|
|
414
|
+
args = call.get("args") or {}
|
|
415
|
+
# Capture bash commands + note writes for the end-of-run
|
|
416
|
+
# summary cli.py builds. Done here (on the call event) so
|
|
417
|
+
# we have the args; tool_result only carries call_id.
|
|
418
|
+
if tool == "bash":
|
|
419
|
+
cmd = str(args.get("command") or "").strip()
|
|
420
|
+
if cmd:
|
|
421
|
+
self.bash_commands.append(cmd)
|
|
422
|
+
elif tool in ("edit", "multi_edit", "write"):
|
|
423
|
+
# All three mutation tools take the file path as ``path``.
|
|
424
|
+
# Record it as a touch so file-touch history knows what
|
|
425
|
+
# this turn changed (outcome attached later by the repl).
|
|
426
|
+
p = str(args.get("path") or "").strip()
|
|
427
|
+
if p and p not in self.files_touched:
|
|
428
|
+
self.files_touched.append(p)
|
|
429
|
+
elif tool == "note":
|
|
430
|
+
title = str(args.get("title") or "").strip()
|
|
431
|
+
if title:
|
|
432
|
+
kind = str(args.get("kind") or "").strip()
|
|
433
|
+
self.notes_written.append((kind, title))
|
|
434
|
+
elif tool == "plan_write":
|
|
435
|
+
# Capture the structured plan steps so the REPL's
|
|
436
|
+
# auto-continue logic can read progress from a stable
|
|
437
|
+
# API instead of regex-parsing the rendered markdown.
|
|
438
|
+
# Lenient coercion: args["steps"] may be a JSON-string
|
|
439
|
+
# in some adapters; loomflow normalises on the tool
|
|
440
|
+
# side but the event we observe predates that. Fall
|
|
441
|
+
# back to the regex parser if shape is unexpected.
|
|
442
|
+
steps = _coerce_plan_steps(args.get("steps"))
|
|
443
|
+
if steps is not None:
|
|
444
|
+
self.last_plan_steps = steps
|
|
445
|
+
arg_str = _summarise_args(args)
|
|
446
|
+
# Per-command sandbox marker: when --sandbox is on, a 🔒 on the
|
|
447
|
+
# bash line confirms the shell is kernel-isolated for THIS call
|
|
448
|
+
# — the launch banner alone left users unsure it still applied.
|
|
449
|
+
lock = " 🔒" if (self._sandbox and tool == "bash") else ""
|
|
450
|
+
console.print(
|
|
451
|
+
Text.assemble(
|
|
452
|
+
(" → ", "bold cyan"),
|
|
453
|
+
(tool, "bold cyan"),
|
|
454
|
+
(lock, "bold green"),
|
|
455
|
+
(f" {arg_str}", "dim"),
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
# Update the spinner label so the user has something to
|
|
459
|
+
# read while this tool runs. delegate is loom-code's
|
|
460
|
+
# workhorse — name the worker; bash gets a clipped command;
|
|
461
|
+
# everything else just shows the tool name.
|
|
462
|
+
self._set_status(_status_label_for(tool, args))
|
|
463
|
+
|
|
464
|
+
def _on_tool_result(self, payload: dict[str, Any]) -> None:
|
|
465
|
+
# Tool came back — model is now picking the next move.
|
|
466
|
+
# Reset to the generic label until the next tool_call (or
|
|
467
|
+
# text stream) overrides it.
|
|
468
|
+
self._set_status("thinking...")
|
|
469
|
+
result = payload.get("result") or {}
|
|
470
|
+
# ToolResult has no `tool` field — only `call_id`. Resolve
|
|
471
|
+
# the tool name via the id->name map built from tool_call
|
|
472
|
+
# events; fall back to the raw call_id if we somehow missed
|
|
473
|
+
# the pairing.
|
|
474
|
+
call_id = str(result.get("call_id", ""))
|
|
475
|
+
tool = self._call_names.get(call_id, call_id)
|
|
476
|
+
output = result.get("output")
|
|
477
|
+
ok = result.get("ok", True)
|
|
478
|
+
error = result.get("error")
|
|
479
|
+
if not ok and error:
|
|
480
|
+
console.print(Text(f" ✗ {error}", style="red"))
|
|
481
|
+
return
|
|
482
|
+
text = str(output) if output is not None else ""
|
|
483
|
+
# Capture the latest living-plan render so the REPL's
|
|
484
|
+
# /plan command can show it after the stream drains.
|
|
485
|
+
is_plan = "plan" in str(tool) and "**GOAL:**" in text
|
|
486
|
+
if is_plan:
|
|
487
|
+
self.last_plan = text
|
|
488
|
+
if not text:
|
|
489
|
+
console.print(Text(" ✓ (no output)", style="dim green"))
|
|
490
|
+
return
|
|
491
|
+
# The living plan is load-bearing — show it in full. Prefer
|
|
492
|
+
# the compact glyph view (built from the structured steps
|
|
493
|
+
# captured on the tool_call); fall back to the raw markdown
|
|
494
|
+
# table only if we somehow didn't capture structured steps.
|
|
495
|
+
if is_plan:
|
|
496
|
+
if self.last_plan_steps:
|
|
497
|
+
console.print(
|
|
498
|
+
_render_plan_glyphs(
|
|
499
|
+
self.last_plan_steps,
|
|
500
|
+
goal=_extract_plan_goal(text),
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
else:
|
|
504
|
+
indented = "\n".join(
|
|
505
|
+
" " + ln for ln in text.splitlines()
|
|
506
|
+
)
|
|
507
|
+
console.print(Text(indented, style="dim"))
|
|
508
|
+
return
|
|
509
|
+
is_verbose = any(
|
|
510
|
+
v in str(tool) for v in _VERBOSE_RESULT_TOOLS
|
|
511
|
+
)
|
|
512
|
+
mult = _VERBOSE_MULTIPLIER if is_verbose else 1
|
|
513
|
+
char_cap = _RESULT_PREVIEW_CHARS * mult
|
|
514
|
+
line_cap = _RESULT_PREVIEW_LINES * mult
|
|
515
|
+
preview = _truncate_preview(
|
|
516
|
+
text, char_cap=char_cap, line_cap=line_cap
|
|
517
|
+
)
|
|
518
|
+
indented = "\n".join(
|
|
519
|
+
" " + ln for ln in preview.splitlines()
|
|
520
|
+
)
|
|
521
|
+
console.print(Text(indented, style="dim"))
|
|
522
|
+
|
|
523
|
+
# ---- permission gate ------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def _on_permission_ask(self, payload: dict[str, Any]) -> None:
|
|
526
|
+
self._end_text()
|
|
527
|
+
call = payload.get("call") or {}
|
|
528
|
+
console.print(
|
|
529
|
+
Text(
|
|
530
|
+
f" ⚠ permission requested for "
|
|
531
|
+
f"{call.get('tool', '?')}",
|
|
532
|
+
style="yellow",
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
def _on_permission_decision(self, payload: dict[str, Any]) -> None:
|
|
537
|
+
decision = payload.get("decision") or {}
|
|
538
|
+
allowed = decision.get("allow", decision.get("allowed"))
|
|
539
|
+
if allowed is False:
|
|
540
|
+
console.print(Text(" ⚠ denied", style="red"))
|
|
541
|
+
|
|
542
|
+
# ---- lifecycle ------------------------------------------------------
|
|
543
|
+
|
|
544
|
+
def _on_error(self, payload: dict[str, Any]) -> None:
|
|
545
|
+
self._end_text()
|
|
546
|
+
msg = str(payload.get("error") or payload.get("message") or "?")
|
|
547
|
+
# loomflow BOTH emits the error event AND re-raises the same
|
|
548
|
+
# exception, so when it re-raises the consumer (repl/cli)
|
|
549
|
+
# prints the flattened, classified form right after this
|
|
550
|
+
# handler — printing the opaque anyio wrapper here too would be
|
|
551
|
+
# a duplicate. Suppress ONLY the BARE wrapper ("unhandled
|
|
552
|
+
# errors in a TaskGroup (N sub-exception)") with nothing else
|
|
553
|
+
# of substance: if the message ALSO carries a real cause (a
|
|
554
|
+
# worker error the run RECOVERS from and does not re-raise —
|
|
555
|
+
# the consumer never sees it), we must still show it, or the
|
|
556
|
+
# user gets a silently degraded answer.
|
|
557
|
+
low = msg.lower()
|
|
558
|
+
# The bare wrapper is exactly "unhandled errors in a TaskGroup
|
|
559
|
+
# (N sub-exception[s])" — it ends at the paren note with no
|
|
560
|
+
# real cause appended. A message that carries a cause has more
|
|
561
|
+
# after the "sub-exception)" — keep those.
|
|
562
|
+
tail = low.split("sub-exception", 1)[-1]
|
|
563
|
+
bare_wrapper = (
|
|
564
|
+
"unhandled errors in a taskgroup" in low
|
|
565
|
+
and tail.strip(" s)") == ""
|
|
566
|
+
)
|
|
567
|
+
if bare_wrapper:
|
|
568
|
+
return
|
|
569
|
+
console.print(Text(f"\n✗ error: {msg}", style="bold red"))
|
|
570
|
+
|
|
571
|
+
def _on_completed(self, payload: dict[str, Any]) -> None:
|
|
572
|
+
self._end_text()
|
|
573
|
+
# _end_text restarts the spinner ("thinking...") to bridge to
|
|
574
|
+
# the NEXT event — but this is the LAST event, so kill it now.
|
|
575
|
+
# Left running, it renders a dangling spinner line that Rich
|
|
576
|
+
# then clears, leaving the dead gap between the answer and the
|
|
577
|
+
# turn-summary rule the user saw.
|
|
578
|
+
self._pause_status()
|
|
579
|
+
result = payload.get("result") or payload
|
|
580
|
+
self.last_result = result
|
|
581
|
+
# Fallback: if the run produced a final answer but nothing
|
|
582
|
+
# streamed (buffered .run-style emission, or a turn that
|
|
583
|
+
# ended in pure text the chunk handler somehow missed),
|
|
584
|
+
# print the output so the user is never left staring at a
|
|
585
|
+
# blank turn. Guarded by _any_text so streamed prose is
|
|
586
|
+
# never double-printed.
|
|
587
|
+
output = str(result.get("output") or "").strip()
|
|
588
|
+
if output and not self._any_text:
|
|
589
|
+
console.print()
|
|
590
|
+
console.print(Text("● loom", style="bold cyan"))
|
|
591
|
+
rendered = self._render_markdown(output)
|
|
592
|
+
if rendered is not None:
|
|
593
|
+
console.print(rendered)
|
|
594
|
+
else:
|
|
595
|
+
console.print(output, markup=False, highlight=False)
|
|
596
|
+
# No trailing blank here — the REPL's end-of-turn summary rule
|
|
597
|
+
# (_print_turn_summary) closes the turn. A blank here just added
|
|
598
|
+
# dead space between the answer and that rule.
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# Status → (glyph, Rich style) for the compact plan view. ■ done /
|
|
602
|
+
# ▸ doing / □ todo / ⊘ skipped / ✗ blocked — scannable at a glance,
|
|
603
|
+
# the way Claude Code / modern TODO panels render task state.
|
|
604
|
+
_PLAN_GLYPHS: dict[str, tuple[str, str]] = {
|
|
605
|
+
"done": ("■", "green"),
|
|
606
|
+
"doing": ("▸", "bold yellow"),
|
|
607
|
+
"todo": ("□", "dim"),
|
|
608
|
+
"skipped": ("⊘", "dim"),
|
|
609
|
+
"blocked": ("✗", "red"),
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _extract_plan_goal(markdown: str) -> str:
|
|
614
|
+
"""Pull the goal line out of loomflow's rendered plan markdown
|
|
615
|
+
(``**GOAL:** ...``) so the glyph header can show it."""
|
|
616
|
+
for line in markdown.splitlines():
|
|
617
|
+
stripped = line.strip()
|
|
618
|
+
if stripped.startswith("**GOAL:**"):
|
|
619
|
+
return stripped.replace("**GOAL:**", "").strip()
|
|
620
|
+
return ""
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _render_plan_glyphs(
|
|
624
|
+
steps: list[dict[str, Any]], *, goal: str = ""
|
|
625
|
+
) -> Text:
|
|
626
|
+
"""Render a living plan as a compact glyph list instead of a
|
|
627
|
+
markdown table. Far faster to scan mid-run ("what's left?")
|
|
628
|
+
than the bordered table.
|
|
629
|
+
|
|
630
|
+
Layout::
|
|
631
|
+
|
|
632
|
+
◆ <goal> · 1/3 done · 1 blocked
|
|
633
|
+
■ Fix shell injection — rewrote without shell=True
|
|
634
|
+
▸ Fix eval usage (doing)
|
|
635
|
+
□ Fix path traversal
|
|
636
|
+
⊘ Update changelog › skipped: outside ask
|
|
637
|
+
|
|
638
|
+
Done steps show their ``finding`` inline (dim); skipped/blocked
|
|
639
|
+
steps show the reason right-flagged with ``›``. Built with Rich
|
|
640
|
+
``Text.append`` (not markup) so step descriptions containing
|
|
641
|
+
``[`` don't get mis-parsed as style tags.
|
|
642
|
+
"""
|
|
643
|
+
t = Text()
|
|
644
|
+
total = len(steps)
|
|
645
|
+
done = sum(1 for s in steps if str(s.get("status")) == "done")
|
|
646
|
+
blocked = sum(
|
|
647
|
+
1 for s in steps if str(s.get("status")) == "blocked"
|
|
648
|
+
)
|
|
649
|
+
t.append(" ◆ ", style="bold cyan")
|
|
650
|
+
t.append(goal or "Plan", style="bold")
|
|
651
|
+
t.append(f" · {done}/{total} done", style="dim")
|
|
652
|
+
if blocked:
|
|
653
|
+
t.append(f" · {blocked} blocked", style="dim red")
|
|
654
|
+
t.append("\n")
|
|
655
|
+
for s in steps:
|
|
656
|
+
status = str(s.get("status", "todo"))
|
|
657
|
+
glyph, gstyle = _PLAN_GLYPHS.get(status, ("□", "dim"))
|
|
658
|
+
desc = str(s.get("description", "")).strip()
|
|
659
|
+
finding = (
|
|
660
|
+
str(s.get("finding", "")).replace("\n", " ").strip()
|
|
661
|
+
)
|
|
662
|
+
t.append(f" {glyph} ", style=gstyle)
|
|
663
|
+
if status == "done":
|
|
664
|
+
t.append(desc, style="dim")
|
|
665
|
+
elif status == "doing":
|
|
666
|
+
t.append(desc, style="bold")
|
|
667
|
+
t.append(" (doing)", style="dim yellow")
|
|
668
|
+
else:
|
|
669
|
+
t.append(desc)
|
|
670
|
+
if finding:
|
|
671
|
+
if status in ("skipped", "blocked"):
|
|
672
|
+
t.append(f" › {finding}", style="dim")
|
|
673
|
+
elif status == "done":
|
|
674
|
+
# Cap the inline finding so a verbose one doesn't
|
|
675
|
+
# blow the line width.
|
|
676
|
+
clip = finding if len(finding) <= 60 else finding[:59] + "…"
|
|
677
|
+
t.append(f" — {clip}", style="dim")
|
|
678
|
+
t.append("\n")
|
|
679
|
+
return t
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _coerce_plan_steps(raw: Any) -> list[dict[str, Any]] | None:
|
|
683
|
+
"""Normalise the ``plan_write`` ``steps`` arg into a list of dicts.
|
|
684
|
+
|
|
685
|
+
loomflow's plan tool accepts several shapes (native list, JSON
|
|
686
|
+
string, ``{"steps":[…]}`` wrapper) on the tool side, but the
|
|
687
|
+
``tool_call`` event we observe carries whatever the model
|
|
688
|
+
emitted — usually a list of dicts. Be lenient about the shape;
|
|
689
|
+
return ``None`` for anything we can't interpret so the auto-
|
|
690
|
+
continue logic falls back to markdown parsing.
|
|
691
|
+
"""
|
|
692
|
+
import json
|
|
693
|
+
|
|
694
|
+
if isinstance(raw, str):
|
|
695
|
+
try:
|
|
696
|
+
raw = json.loads(raw)
|
|
697
|
+
except json.JSONDecodeError:
|
|
698
|
+
return None
|
|
699
|
+
if isinstance(raw, dict) and "steps" in raw:
|
|
700
|
+
raw = raw["steps"]
|
|
701
|
+
if not isinstance(raw, list):
|
|
702
|
+
return None
|
|
703
|
+
out: list[dict[str, Any]] = []
|
|
704
|
+
for item in raw:
|
|
705
|
+
if not isinstance(item, dict):
|
|
706
|
+
continue
|
|
707
|
+
# Normalise the status field — lower-case, default todo.
|
|
708
|
+
status = item.get("status") or "todo"
|
|
709
|
+
if not isinstance(status, str):
|
|
710
|
+
status = "todo"
|
|
711
|
+
status = status.lower().strip()
|
|
712
|
+
if status not in {"todo", "doing", "done", "blocked", "skipped"}:
|
|
713
|
+
status = "todo"
|
|
714
|
+
out.append(
|
|
715
|
+
{
|
|
716
|
+
"description": str(item.get("description", "")).strip(),
|
|
717
|
+
"status": status,
|
|
718
|
+
"finding": str(item.get("finding") or "").strip() or None,
|
|
719
|
+
}
|
|
720
|
+
)
|
|
721
|
+
return out
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _summarise_args(args: dict[str, Any]) -> str:
|
|
725
|
+
"""One-line, length-capped arg summary for a tool-call line."""
|
|
726
|
+
parts: list[str] = []
|
|
727
|
+
for k, v in args.items():
|
|
728
|
+
sv = str(v).replace("\n", " ")
|
|
729
|
+
if len(sv) > 60:
|
|
730
|
+
sv = sv[:60] + "…"
|
|
731
|
+
parts.append(f"{k}={sv}")
|
|
732
|
+
return ", ".join(parts)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def banner(
|
|
736
|
+
model: str,
|
|
737
|
+
root: str,
|
|
738
|
+
is_git: bool,
|
|
739
|
+
*,
|
|
740
|
+
sandbox: bool = False,
|
|
741
|
+
sandbox_allow_network: bool = False,
|
|
742
|
+
) -> None:
|
|
743
|
+
"""Print the loom-code startup banner.
|
|
744
|
+
|
|
745
|
+
``sandbox`` shows a visible badge in the header (``🔒 sandboxed``)
|
|
746
|
+
plus a one-line explanation, so the user can tell at a glance that
|
|
747
|
+
the coder's bash is kernel-isolated — otherwise the protection is
|
|
748
|
+
invisible (it only surfaces when a command tries to escape)."""
|
|
749
|
+
git_tag = "git" if is_git else "no-git"
|
|
750
|
+
parts = [
|
|
751
|
+
("loom-code", "bold"),
|
|
752
|
+
(" ", ""),
|
|
753
|
+
(f"{model}", "cyan"),
|
|
754
|
+
(" · ", "dim"),
|
|
755
|
+
(f"{root}", "dim"),
|
|
756
|
+
(" · ", "dim"),
|
|
757
|
+
(git_tag, "dim"),
|
|
758
|
+
]
|
|
759
|
+
if sandbox:
|
|
760
|
+
parts += [(" · ", "dim"), ("🔒 sandboxed", "bold green")]
|
|
761
|
+
console.print()
|
|
762
|
+
console.print(Text.assemble(*parts))
|
|
763
|
+
console.print(
|
|
764
|
+
Text(" loomflow-native coding agent", style="dim italic")
|
|
765
|
+
)
|
|
766
|
+
if sandbox:
|
|
767
|
+
net = (
|
|
768
|
+
"network ON" if sandbox_allow_network else "no network"
|
|
769
|
+
)
|
|
770
|
+
console.print(
|
|
771
|
+
Text(
|
|
772
|
+
f" bash runs in an OS sandbox — writes limited to "
|
|
773
|
+
f"this repo, {net}",
|
|
774
|
+
style="dim green",
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
console.print()
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def print_code(text: str, lexer: str = "python") -> None:
|
|
781
|
+
"""Render a code block with syntax highlighting (used by the
|
|
782
|
+
diff-approval UI in Phase 2)."""
|
|
783
|
+
console.print(Syntax(text, lexer, theme="ansi_dark"))
|