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/approval.py ADDED
@@ -0,0 +1,616 @@
1
+ """The terminal diff-approval gate.
2
+
3
+ When ``StandardPermissions`` flags a destructive tool call
4
+ (``write`` / ``edit`` / ``bash``), loomflow routes it to the
5
+ Agent's ``approval_handler``. This module is that handler: it
6
+ renders WHAT the agent wants to do — a unified diff for edits, the
7
+ full content for writes, the command for bash — and asks the user
8
+ y / n / a (allow-all-this-session).
9
+
10
+ Pure UI. The decision logic loomflow owns; we just collect the
11
+ human's answer.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import difflib
17
+ import sys
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import anyio
23
+ from rich.panel import Panel
24
+ from rich.syntax import Syntax
25
+ from rich.text import Text
26
+
27
+ from .render import console
28
+
29
+ # History-/repo-destroying shell commands. These are NOT covered by the
30
+ # "allow all this session" choice — even after the user picks 'a', one
31
+ # of these still demands a fresh, explicit confirmation, because they're
32
+ # irreversible in a way ordinary edits aren't. A real incident motivated
33
+ # this: the agent ran ``rm -rf .git`` on request and silently deleted a
34
+ # repo's entire history with no extra friction. Patterns are matched
35
+ # against the normalized (lowercased, whitespace-collapsed) command.
36
+ _DANGER_PATTERNS: tuple[str, ...] = (
37
+ "rm -rf .git",
38
+ "rm -r .git",
39
+ "rm -fr .git",
40
+ "rm -rf .git/",
41
+ "git reset --hard",
42
+ "git clean -fd",
43
+ "git clean -xfd",
44
+ "git push --force",
45
+ "git push -f",
46
+ "git push --force-with-lease",
47
+ "git branch -d", # force-delete branch
48
+ "git update-ref -d",
49
+ "rm -rf /",
50
+ )
51
+
52
+
53
+ def _is_danger_command(tool: str, args: dict[str, Any]) -> str | None:
54
+ """Return a human label if this call is a history-/repo-destroying
55
+ bash command, else None. Only ``bash`` can carry these — edits and
56
+ writes are bounded to a single file and already gated.
57
+
58
+ The normalized match collapses ``rm -rf .git`` and quoting
59
+ variants down so a stray space can't slip a destructive command
60
+ past the check. False positives are acceptable here: an extra
61
+ confirmation on a benign ``git reset --hard`` to a known-safe ref
62
+ costs one keypress; a missed ``rm -rf .git`` costs the repo."""
63
+ if tool != "bash":
64
+ return None
65
+ cmd = str(args.get("command", "")).lower()
66
+ norm = " ".join(cmd.split())
67
+ for pat in _DANGER_PATTERNS:
68
+ if pat in norm:
69
+ return pat
70
+ return None
71
+
72
+
73
+ def _read_key_raw(fd: int) -> str:
74
+ """Read one LOGICAL key from an ALREADY-raw ``fd``: 'up' / 'down'
75
+ / 'enter' / 'esc' / 'eof' / a single lowercased printable char.
76
+
77
+ Arrow keys arrive as an escape sequence — ``ESC [ A/B`` (normal)
78
+ or ``ESC O A/B`` (application-cursor mode, common over SSH/tmux).
79
+ The bytes can also SPLIT across reads on a slow PTY, so after ESC
80
+ we poll-and-read up to two more bytes rather than assuming they
81
+ land in one ``os.read`` (the earlier one-shot ``os.read(fd, 2)``
82
+ turned a split ↓ into 'esc' → an accidental deny, and left the
83
+ trailing 'A'/'B' in the buffer to be misread as the 'a' hotkey).
84
+
85
+ ``os.read`` on the raw fd, never ``sys.stdin.read`` — Python's
86
+ stdin buffers ahead, hiding continuation bytes from ``select``.
87
+ An empty read is EOF (terminal hangup) → 'eof', which callers
88
+ treat as a safe cancel, never an approval."""
89
+ import os
90
+ import select
91
+
92
+ def _more(timeout: float) -> str:
93
+ r, _, _ = select.select([fd], [], [], timeout)
94
+ if not r:
95
+ return ""
96
+ return os.read(fd, 1).decode("utf-8", "ignore")
97
+
98
+ data = os.read(fd, 1)
99
+ if not data: # EOF / hangup
100
+ return "eof"
101
+ ch = data.decode("utf-8", "ignore")
102
+ if ch in ("\r", "\n"):
103
+ return "enter"
104
+ if ch == "\x03": # Ctrl-C
105
+ return "esc"
106
+ if ch == "\x1b":
107
+ intro = _more(0.05)
108
+ if intro in ("[", "O"): # CSI or SS3 arrows
109
+ final = _more(0.05)
110
+ return {"A": "up", "B": "down"}.get(final, "esc")
111
+ return "esc"
112
+ return ch.lower()
113
+
114
+
115
+ def _read_key() -> str:
116
+ """Single-key read that manages its own raw-mode window. Prefer
117
+ :func:`_read_key_raw` inside a selector that enters raw mode ONCE
118
+ (no per-key termios churn); this wrapper is for one-off reads.
119
+
120
+ Non-TTY (piped/CI/tests): a line read whose EOF maps to 'eof'
121
+ (fail-closed), NOT 'enter'."""
122
+ if not sys.stdin.isatty():
123
+ try:
124
+ line = sys.stdin.readline()
125
+ except Exception:
126
+ return "eof"
127
+ if line == "":
128
+ return "eof"
129
+ ch = line.strip()[:1].lower()
130
+ return ch or "enter"
131
+ try:
132
+ import termios
133
+ import tty
134
+
135
+ fd = sys.stdin.fileno()
136
+ old = termios.tcgetattr(fd)
137
+ try:
138
+ tty.setraw(fd)
139
+ return _read_key_raw(fd)
140
+ finally:
141
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
142
+ except Exception:
143
+ return _read_single_key() or "eof"
144
+
145
+
146
+ def _select_option(options: list[tuple[str, str]], default: int = 0) -> str:
147
+ """A Claude-Code-style vertical selector. ``options`` is a list of
148
+ ``(key, label)``; returns the chosen key.
149
+
150
+ Navigation: ↑/↓ move, Enter confirms, 1-9 jump-select, the
151
+ option's own hotkey (y/n/a) selects directly. Esc / Ctrl-C / EOF
152
+ pick the LAST option — which callers make the SAFE choice ("No"),
153
+ so a closed stdin, a hangup, or a startled Ctrl-C can never
154
+ approve. Rendering is plain ANSI — the block redraws in place on
155
+ every keypress, which works in any VT terminal and deliberately
156
+ avoids nesting a Rich Live inside the REPL's spinner (a known
157
+ recursion hazard)."""
158
+ n = len(options)
159
+ if not sys.stdin.isatty():
160
+ # Non-interactive: a single letter picks by hotkey; EOF/empty
161
+ # is the SAFE last option (never the default-yes).
162
+ ch = _read_key()
163
+ for key, _label in options:
164
+ if ch == key:
165
+ return key
166
+ if ch == "enter":
167
+ return options[default][0]
168
+ return options[-1][0] # eof / esc / unknown → safe
169
+
170
+ idx = default
171
+
172
+ def _draw(first: bool) -> None:
173
+ out = sys.stdout
174
+ if not first:
175
+ out.write(f"\x1b[{n}A") # cursor up n rows, to block start
176
+ for i, (_key, label) in enumerate(options):
177
+ # ``\r`` first: in RAW mode ``tty.setraw`` disables NL→CRNL
178
+ # translation, so a bare ``\n`` drops a row WITHOUT
179
+ # returning to column 0 — each line would start further
180
+ # right than the last (the staircase). Carriage-return to
181
+ # column 0, clear the whole line, then print.
182
+ out.write("\r\x1b[2K")
183
+ if i == idx:
184
+ out.write(f" \x1b[36;1m❯ {i + 1}. {label}\x1b[0m")
185
+ else:
186
+ out.write(f" \x1b[2m{i + 1}. {label}\x1b[0m")
187
+ out.write("\r\n") # explicit CR+LF for raw mode
188
+ out.flush()
189
+
190
+ # Enter raw mode ONCE for the whole selector session — no
191
+ # per-keypress termios churn, and no cooked-mode gap between keys
192
+ # where type-ahead would echo raw escape bytes onto the prompt.
193
+ import termios
194
+ import tty
195
+
196
+ fd = sys.stdin.fileno()
197
+ try:
198
+ old = termios.tcgetattr(fd)
199
+ except Exception:
200
+ old = None
201
+ if old is not None:
202
+ tty.setraw(fd)
203
+ try:
204
+ _draw(first=True)
205
+ while True:
206
+ key = _read_key_raw(fd) if old is not None else _read_key()
207
+ if key == "up":
208
+ idx = (idx - 1) % n
209
+ elif key == "down":
210
+ idx = (idx + 1) % n
211
+ elif key == "enter":
212
+ return options[idx][0]
213
+ elif key in ("esc", "eof"):
214
+ return options[-1][0]
215
+ elif key.isdigit() and 1 <= int(key) <= n:
216
+ return options[int(key) - 1][0]
217
+ else:
218
+ for k, _label in options:
219
+ if key == k:
220
+ return k
221
+ continue # unknown key: ignore, keep waiting
222
+ _draw(first=False)
223
+ finally:
224
+ if old is not None:
225
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
226
+
227
+
228
+ def _read_single_key() -> str:
229
+ """Read ONE keypress without waiting for Enter.
230
+
231
+ POSIX raw-mode read; falls back to ``msvcrt`` on Windows and to a
232
+ line read when stdin isn't a TTY (piped input, tests). Returning a
233
+ single character lets the approval prompt act like a button row —
234
+ the user reported the type-then-Enter form as a real obstacle.
235
+ """
236
+ if not sys.stdin.isatty():
237
+ # Non-interactive (piped/CI/tests) — degrade to a line read so
238
+ # the gate still resolves instead of blocking forever.
239
+ try:
240
+ return sys.stdin.readline().strip()[:1].lower()
241
+ except Exception:
242
+ return ""
243
+ try:
244
+ import termios
245
+ import tty
246
+
247
+ fd = sys.stdin.fileno()
248
+ old = termios.tcgetattr(fd)
249
+ try:
250
+ tty.setraw(fd)
251
+ return sys.stdin.read(1)
252
+ finally:
253
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
254
+ except Exception:
255
+ try:
256
+ import msvcrt
257
+
258
+ return msvcrt.getch().decode("utf-8", "ignore")
259
+ except Exception:
260
+ try:
261
+ return sys.stdin.readline().strip()[:1].lower()
262
+ except Exception:
263
+ return ""
264
+
265
+
266
+ def _question_for(tool: str) -> str:
267
+ """The bold question above the option list, per tool."""
268
+ return {
269
+ "edit": "Apply this edit?",
270
+ "multi_edit": "Apply these edits?",
271
+ "write": "Write this file?",
272
+ "bash": "Run this command?",
273
+ }.get(tool, f"Allow {tool}?")
274
+
275
+
276
+ async def auto_approve(call: Any, user_id: str | None = None) -> bool:
277
+ """A non-interactive approval handler that allows everything.
278
+
279
+ For unattended runs — CI, scripted use — where there's no
280
+ human at a TTY to answer the y/n/a prompt and the working tree
281
+ is disposable. Wired in via ``loom-code --yes``.
282
+
283
+ NEVER point this at a repo you care about: it lets the agent
284
+ write / edit / run shell commands with no gate at all.
285
+ """
286
+ return True
287
+
288
+
289
+ class ApprovalGate:
290
+ """Stateful approval handler — remembers an 'allow all this
291
+ session' choice so the user isn't asked twice for the same
292
+ kind of risk.
293
+
294
+ Pass :meth:`handler` as the Agent's ``approval_handler``.
295
+ """
296
+
297
+ def __init__(
298
+ self,
299
+ *,
300
+ pause_spinner: Callable[[], None] | None = None,
301
+ resume_spinner: Callable[[], None] | None = None,
302
+ rules: Any | None = None,
303
+ mode: Any | None = None,
304
+ project_root: Any | None = None,
305
+ ) -> None:
306
+ # Once the user picks 'a' (allow all), every subsequent
307
+ # destructive call this session auto-approves. Reset only
308
+ # by restarting loom-code.
309
+ self._allow_all = False
310
+ # Permission rules (allow/ask/deny globs) + session mode
311
+ # (default/accept-edits/plan/yolo). Imported here to keep the
312
+ # module import-light. The REPL swaps ``mode`` via /mode.
313
+ from .permissions import Mode, Rules
314
+
315
+ self.rules = rules if rules is not None else Rules()
316
+ self.mode = mode if mode is not None else Mode.DEFAULT
317
+ # Project root — used to force a confirm on edits OUTSIDE it
318
+ # even in auto-approve modes. None disables the check (the
319
+ # outside-edit path just behaves like any other edit then).
320
+ self.project_root = (
321
+ Path(project_root).resolve() if project_root else None
322
+ )
323
+ # The REPL drives a ``console.status`` spinner for the whole
324
+ # turn. Its Live refresh shares the cursor line, so leaving it
325
+ # running corrupts the approval prompt's keystrokes (mangled
326
+ # input → endless "select an option" re-prompt). We pause it
327
+ # around the prompt and resume after.
328
+ self._pause_spinner = pause_spinner or (lambda: None)
329
+ self._resume_spinner = resume_spinner or (lambda: None)
330
+
331
+ async def handler(
332
+ self, call: Any, user_id: str | None = None
333
+ ) -> bool:
334
+ """The ``approval_handler`` loomflow calls. ``call`` is a
335
+ ``ToolCall``; return True to allow, False to deny.
336
+
337
+ Policy resolves in ONE place — :func:`permissions.decide` — so
338
+ precedence is consistent: **deny > ask > allow > mode**. Then:
339
+
340
+ * DENY → refuse, unconditionally (a deny rule / plan mode is
341
+ absolute; nothing below overrides it).
342
+ * ASK → the interactive prompt.
343
+ * ALLOW → skip the prompt, UNLESS the command is one of the
344
+ irreversible history-/repo-destroyers, which ALWAYS get a
345
+ fresh high-friction confirm even under allow-all / yolo.
346
+
347
+ Session 'allow all' is modelled as an effective yolo mode
348
+ INSIDE ``decide`` (not a shortcut above it), so an explicit
349
+ ``ask`` rule the user configured still forces the prompt —
350
+ one careless 'a' can't silently defeat their own ask-rules."""
351
+ tool = getattr(call, "tool", "?")
352
+ args = getattr(call, "args", {}) or {}
353
+
354
+ from .permissions import Decision, Mode, decide
355
+
356
+ effective_mode = Mode.YOLO if self._allow_all else self.mode
357
+ decision = decide(tool, args, self.rules, effective_mode)
358
+
359
+ # An edit/write to a file OUTSIDE the project always shows the
360
+ # diff and asks — even in accept-edits / yolo / allow-all /
361
+ # --yes. Consent (an @-mention) lets the edit tool TARGET the
362
+ # file; it does not waive the human confirmation. Without this,
363
+ # /mode accept-edits + an @-mention of ~/.zshrc would silently
364
+ # mutate a dotfile. Never UPGRADES a deny.
365
+ if (
366
+ decision is Decision.ALLOW
367
+ and tool in ("edit", "multi_edit", "write")
368
+ and self._is_outside_project(args.get("path"))
369
+ ):
370
+ decision = Decision.ASK
371
+
372
+ if decision is Decision.DENY:
373
+ self._pause_spinner()
374
+ try:
375
+ console.print(
376
+ Text(
377
+ f" ⊘ {tool} denied by permission policy "
378
+ f"({self.mode.value})",
379
+ style="red",
380
+ )
381
+ )
382
+ finally:
383
+ self._resume_spinner()
384
+ return False
385
+
386
+ # Danger gate — irreversible commands ALWAYS re-confirm, even
387
+ # when policy said ALLOW. It never UPGRADES a deny (handled
388
+ # above) — only forces friction on an otherwise-allowed
389
+ # destructive command.
390
+ danger = _is_danger_command(tool, args)
391
+ if danger is not None:
392
+ return await self._confirm_danger(danger, args)
393
+
394
+ if decision is Decision.ALLOW:
395
+ return True
396
+
397
+ # Pause the spinner BEFORE any console output — even the
398
+ # header lines below get garbled if the Live is still
399
+ # repainting the cursor line.
400
+ self._pause_spinner()
401
+ try:
402
+ console.print()
403
+ self._render_header(tool, args)
404
+ self._render_preview(tool, args)
405
+ console.print()
406
+ console.print(
407
+ Text(f" {_question_for(tool)}", style="bold")
408
+ )
409
+ # Selector runs on a worker thread so the raw-mode key
410
+ # reads don't stall the anyio event loop.
411
+ choice = await anyio.to_thread.run_sync(self._ask)
412
+ finally:
413
+ self._resume_spinner()
414
+ # ``_ask`` already echoed the choice — no second line here
415
+ # (the old "→ denied" after "→ no" read as a double refusal).
416
+ if choice == "a":
417
+ self._allow_all = True
418
+ return True
419
+ return choice == "y"
420
+
421
+ # ---- internals ------------------------------------------------------
422
+
423
+ def _is_outside_project(self, path: Any) -> bool:
424
+ """True if ``path`` resolves outside the project root. False
425
+ when no root is configured or the path is unusable (fail
426
+ toward the normal in-project flow — the edit tool's own
427
+ workdir guard still refuses genuinely-outside writes)."""
428
+ if self.project_root is None or not path:
429
+ return False
430
+ try:
431
+ target = (self.project_root / Path(path)).resolve()
432
+ target.relative_to(self.project_root)
433
+ return False
434
+ except ValueError:
435
+ return True
436
+ except OSError:
437
+ return False
438
+
439
+ async def _confirm_danger(
440
+ self, label: str, args: dict[str, Any]
441
+ ) -> bool:
442
+ """High-friction confirm for an irreversible command. Unlike the
443
+ normal gate there is NO 'allow all', and the default (Enter /
444
+ any non-'y' key / Esc) is DENY — the user must deliberately type
445
+ 'y' to proceed. Never auto-approves, regardless of session state
446
+ or ``--yes``-style handlers wrapped around this gate."""
447
+ self._pause_spinner()
448
+ try:
449
+ console.print()
450
+ console.print(
451
+ Text(
452
+ f" ⛔ DESTRUCTIVE: this would run '{label}' — it is "
453
+ "IRREVERSIBLE",
454
+ style="bold red",
455
+ )
456
+ )
457
+ cmd = str(args.get("command", ""))
458
+ console.print(Syntax(cmd, "bash", theme="ansi_dark"))
459
+ console.print(
460
+ Text(
461
+ " This permanently destroys history / data and is "
462
+ "NOT covered by 'allow all'.",
463
+ style="red",
464
+ )
465
+ )
466
+ console.print(
467
+ " [bold]Type 'y' to confirm, anything else cancels:[/bold]"
468
+ " ",
469
+ end="",
470
+ highlight=False,
471
+ )
472
+ choice = await anyio.to_thread.run_sync(_read_single_key)
473
+ finally:
474
+ self._resume_spinner()
475
+ if choice in ("y", "Y"):
476
+ console.print("[red]confirmed[/red]")
477
+ return True
478
+ console.print("[green]cancelled[/green]")
479
+ return False
480
+
481
+ def _ask(self) -> str:
482
+ """Blocking option-selector. Runs on a worker thread.
483
+
484
+ Claude-Code-style vertical menu: ↑/↓ + Enter, number keys, or
485
+ the y/a/n hotkeys. Esc / Ctrl-C picks "No" so a startled user
486
+ can always back out safely. Returns 'y' / 'a' / 'n'."""
487
+ choice = _select_option(
488
+ [
489
+ ("y", "Yes"),
490
+ ("a", "Yes, and don't ask again this session"),
491
+ ("n", "No (esc)"),
492
+ ],
493
+ default=0,
494
+ )
495
+ echo = {
496
+ "y": Text(" → yes", style="green"),
497
+ "a": Text(" → yes, allowing all this session", "yellow"),
498
+ "n": Text(" → no", style="red"),
499
+ }[choice]
500
+ console.print(echo)
501
+ return choice
502
+
503
+ def _render_header(self, tool: str, args: dict[str, Any]) -> None:
504
+ """One bold title line naming the action + its target —
505
+ ``● Edit src/main.py`` — Claude-Code-style, replacing the
506
+ old '⚠ tool wants to run:' warning shout."""
507
+ target = (
508
+ str(args.get("command", "")).strip()
509
+ if tool == "bash"
510
+ else str(args.get("path", "")).strip()
511
+ )
512
+ if len(target) > 60:
513
+ target = target[:60] + "…"
514
+ label = {
515
+ "edit": "Edit",
516
+ "multi_edit": "Edit",
517
+ "write": "Write",
518
+ "bash": "Run",
519
+ }.get(tool, tool)
520
+ console.print(
521
+ Text.assemble(
522
+ (" ● ", "cyan"),
523
+ (f"{label}", "bold"),
524
+ (" ", ""),
525
+ (target, "dim"),
526
+ )
527
+ )
528
+
529
+ def _render_preview(self, tool: str, args: dict[str, Any]) -> None:
530
+ """Show the user exactly what's about to happen, inside a
531
+ rounded panel so the preview reads as one contained artifact
532
+ (Claude-Code-style) rather than loose lines."""
533
+ body: Any
534
+ if tool == "edit":
535
+ body = self._edit_diff_renderable(args)
536
+ elif tool == "write":
537
+ path = args.get("path", "?")
538
+ content = str(args.get("content", ""))
539
+ preview = content if len(content) <= 800 else (
540
+ content[:800] + f"\n… (+{len(content) - 800} chars)"
541
+ )
542
+ body = Syntax(
543
+ preview, _lexer_for(path), theme="ansi_dark",
544
+ line_numbers=False,
545
+ )
546
+ elif tool == "bash":
547
+ cmd = str(args.get("command", ""))
548
+ body = Syntax(cmd, "bash", theme="ansi_dark")
549
+ else:
550
+ # Unknown destructive tool — show raw args.
551
+ lines = []
552
+ for k, v in args.items():
553
+ sv = str(v)
554
+ if len(sv) > 200:
555
+ sv = sv[:200] + "…"
556
+ lines.append(f"{k} = {sv}")
557
+ body = Text("\n".join(lines), style="dim")
558
+ console.print(
559
+ Panel(
560
+ body,
561
+ border_style="dim",
562
+ padding=(0, 1),
563
+ expand=False,
564
+ )
565
+ )
566
+
567
+ def _edit_diff_renderable(self, args: dict[str, Any]) -> Any:
568
+ """A unified-diff renderable for an ``edit`` call so the user
569
+ sees the change in context, not two opaque strings."""
570
+ path = args.get("path", "?")
571
+ old = str(args.get("old_string", ""))
572
+ new = str(args.get("new_string", ""))
573
+ # No keepends + join on "\n": with ``lineterm=""`` the header
574
+ # lines carry no newline of their own, so keepends-content
575
+ # mixed with them used to collapse the whole diff onto one
576
+ # wrapped line.
577
+ diff = difflib.unified_diff(
578
+ old.splitlines(),
579
+ new.splitlines(),
580
+ fromfile=f"{path} (before)",
581
+ tofile=f"{path} (after)",
582
+ lineterm="",
583
+ )
584
+ body = "\n".join(diff)
585
+ if not body.strip():
586
+ return Text(f"edit {path} (no textual change?)", "dim")
587
+ return Syntax(body, "diff", theme="ansi_dark")
588
+
589
+
590
+ def _lexer_for(path: str) -> str:
591
+ """Best-effort lexer name from a file extension."""
592
+ ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
593
+ return {
594
+ "py": "python",
595
+ "js": "javascript",
596
+ "ts": "typescript",
597
+ "tsx": "tsx",
598
+ "jsx": "jsx",
599
+ "rs": "rust",
600
+ "go": "go",
601
+ "rb": "ruby",
602
+ "java": "java",
603
+ "c": "c",
604
+ "h": "c",
605
+ "cpp": "cpp",
606
+ "sh": "bash",
607
+ "bash": "bash",
608
+ "json": "json",
609
+ "toml": "toml",
610
+ "yaml": "yaml",
611
+ "yml": "yaml",
612
+ "md": "markdown",
613
+ "html": "html",
614
+ "css": "css",
615
+ "sql": "sql",
616
+ }.get(ext, "text")