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.
Files changed (58) hide show
  1. loom_code/__init__.py +22 -0
  2. loom_code/_post_commit.py +119 -0
  3. loom_code/agent.py +544 -0
  4. loom_code/approval.py +616 -0
  5. loom_code/browse/__init__.py +291 -0
  6. loom_code/browse/act.py +467 -0
  7. loom_code/browse/observe.py +249 -0
  8. loom_code/browse/session.py +96 -0
  9. loom_code/browse/verify.py +194 -0
  10. loom_code/checkpoint.py +283 -0
  11. loom_code/cli.py +495 -0
  12. loom_code/code_index.py +703 -0
  13. loom_code/compact.py +143 -0
  14. loom_code/consent.py +47 -0
  15. loom_code/credentials.py +527 -0
  16. loom_code/edit_tool.py +635 -0
  17. loom_code/extensions.py +522 -0
  18. loom_code/file_history.py +322 -0
  19. loom_code/file_tools.py +93 -0
  20. loom_code/git_hook.py +200 -0
  21. loom_code/grep_tool.py +430 -0
  22. loom_code/hooks.py +297 -0
  23. loom_code/loominit/__init__.py +23 -0
  24. loom_code/loominit/_ast_walk.py +429 -0
  25. loom_code/loominit/_files.py +284 -0
  26. loom_code/loominit/_graph.py +141 -0
  27. loom_code/loominit/_resolve.py +392 -0
  28. loom_code/loominit/_tests_map.py +108 -0
  29. loom_code/loominit/extractor.py +332 -0
  30. loom_code/loominit/repomap.py +225 -0
  31. loom_code/loominit/schema.py +242 -0
  32. loom_code/lsp_tools.py +396 -0
  33. loom_code/mcp_host.py +79 -0
  34. loom_code/operator.py +449 -0
  35. loom_code/paste.py +97 -0
  36. loom_code/paths.py +52 -0
  37. loom_code/permissions.py +177 -0
  38. loom_code/project.py +104 -0
  39. loom_code/prompts.py +451 -0
  40. loom_code/render.py +783 -0
  41. loom_code/repl.py +4080 -0
  42. loom_code/rules.py +267 -0
  43. loom_code/sandboxed_bash.py +176 -0
  44. loom_code/scribe.py +88 -0
  45. loom_code/skills/__init__.py +16 -0
  46. loom_code/skills/graphify/SKILL.md +97 -0
  47. loom_code/skills/graphify/tools.py +570 -0
  48. loom_code/trust.py +216 -0
  49. loom_code/turn.py +169 -0
  50. loom_code/web_fetch.py +370 -0
  51. loom_code/workers.py +758 -0
  52. loom_code/worktree.py +134 -0
  53. loom_code-0.1.1.dist-info/METADATA +224 -0
  54. loom_code-0.1.1.dist-info/RECORD +58 -0
  55. loom_code-0.1.1.dist-info/WHEEL +5 -0
  56. loom_code-0.1.1.dist-info/entry_points.txt +2 -0
  57. loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
  58. 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"))