chad-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
chad/render.py ADDED
@@ -0,0 +1,462 @@
1
+ """Terminal rendering for chad (extracted from agent.py).
2
+
3
+ Turns raw model tokens and tool results into clean display events, and renders a
4
+ compact, Claude-style activity view (a verb + target on one line, then a short
5
+ summary — line counts for reads, a +/- diff for edits, a few lines of bash output).
6
+ The `_emit(kind, text)` callback contract is the boundary: the plain REPL uses
7
+ `_default_emit` (colored stdout); the TUI passes its own emitter.
8
+
9
+ Pure presentation — no model, no agent state. `agent` re-exports the public names
10
+ (render_tool_start/render_tool_result/_default_emit/_StreamView/confirm_preview/
11
+ the C_* colors) so existing importers (tui.py, cli.py) keep working unchanged.
12
+ """
13
+
14
+ import difflib
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+
20
+ C_DIM = "\033[2m"; C_CYAN = "\033[36m"; C_GREEN = "\033[32m"; C_YEL = "\033[33m"
21
+ C_RED = "\033[31m"; C_BOLD = "\033[1m"; C_RST = "\033[0m"
22
+
23
+ # Optional syntax highlighting (plan 042 item 4). Pygments is a pure-Python OPTIONAL
24
+ # extra (`pip install 'chad[highlight]'`): when present, diff/preview code lines get
25
+ # per-token colors *within* the +/- line coloring; when absent, output is byte-identical
26
+ # to the un-highlighted path. Import-guarded so a bare install never fails, and gated so
27
+ # tests can force the plain path by flipping `_HAS_PYGMENTS`. Never run in the per-token
28
+ # streaming hot path — only in final diffs and confirm-preview bodies (see STOP notes).
29
+ try:
30
+ from pygments import highlight as _pyg_highlight
31
+ from pygments.formatters.terminal import TerminalFormatter as _PygTermFormatter
32
+ from pygments.lexers import get_lexer_by_name, guess_lexer_for_filename
33
+ from pygments.util import ClassNotFound as _PygClassNotFound
34
+ _HAS_PYGMENTS = True
35
+ _PYG_FMT = _PygTermFormatter()
36
+ except ImportError: # pragma: no cover - exercised via monkeypatched _HAS_PYGMENTS
37
+ _HAS_PYGMENTS = False
38
+
39
+
40
+ def _highlight_code(code: str, filename: str = "") -> str:
41
+ """Return `code` with per-token ANSI colors when pygments is available, else the
42
+ input unchanged (byte-identical). Best-effort: any lexer/format failure falls back
43
+ to the plain text, so highlighting can never corrupt a diff line."""
44
+ if not code or not _HAS_PYGMENTS:
45
+ return code
46
+ try:
47
+ try:
48
+ lexer = guess_lexer_for_filename(filename or "x.txt", code)
49
+ except _PygClassNotFound:
50
+ lexer = get_lexer_by_name("text")
51
+ return _pyg_highlight(code, lexer, _PYG_FMT).rstrip("\n")
52
+ except Exception: # noqa: BLE001 — display path; never let highlighting raise
53
+ return code
54
+
55
+
56
+ def confirm_preview(name: str, args: dict, max_lines: int = 6) -> str:
57
+ """A short, human-readable summary of what a mutating tool will do, shown before
58
+ the y/n approval. Bounded to a few lines so it never floods the prompt."""
59
+ def clip(s, n=400):
60
+ s = str(s)
61
+ return s if len(s) <= n else s[:n] + " …"
62
+ def head(s):
63
+ lines = str(s).splitlines() or [str(s)]
64
+ body = "\n".join(" " + clip(ln) for ln in lines[:max_lines])
65
+ if len(lines) > max_lines:
66
+ body += f"\n … (+{len(lines) - max_lines} more lines)"
67
+ return body
68
+ if name == "bash":
69
+ return clip(args.get("command", ""))
70
+ if name == "write":
71
+ return f"{args.get('path','?')}\n{head(args.get('content',''))}"
72
+ if name == "edit":
73
+ return (f"{args.get('path','?')}\n - {clip(args.get('old',''))}"
74
+ f"\n + {clip(args.get('new',''))}")
75
+ if name in ("replace_symbol", "insert_symbol"):
76
+ body = args.get("new") or args.get("code") or ""
77
+ loc = args.get("name", "?") + (f" in {args['path']}" if args.get("path") else "")
78
+ return f"{loc}\n{head(body)}"
79
+ if name == "rename_symbol":
80
+ loc = f" in {args['path']}" if args.get("path") else ""
81
+ return f"{args.get('name','?')} → {args.get('new_name','?')}{loc}"
82
+ if name.startswith("mcp__"):
83
+ # An MCP tool can do anything (write files, hit an API, send a message); show
84
+ # its full arguments so the approval is informed.
85
+ return head(_compact_args(args))
86
+ # fallback: the old behavior
87
+ return str(args.get("command") or args.get("path") or "")
88
+
89
+
90
+ def _compact_args(args) -> str:
91
+ """A compact, single-string view of a tool's arguments for display/preview."""
92
+ if not isinstance(args, dict):
93
+ return str(args)
94
+ try:
95
+ return json.dumps(args, ensure_ascii=False)
96
+ except (TypeError, ValueError):
97
+ return str(args)
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Streaming view: turn raw model tokens into clean display events.
102
+ # The model interleaves reasoning (<think>…</think>) and tool-call syntax
103
+ # (<tool_call>…</tool_call>, <function=…>) with its actual prose. We never want
104
+ # to dump tool-call JSON into the transcript, and reasoning should drive a
105
+ # "Thinking…" indicator rather than a wall of tokens. This recomputes the
106
+ # cleaned view each token and emits only the new suffix, so tags that split
107
+ # across token boundaries are handled correctly.
108
+ # ---------------------------------------------------------------------------
109
+
110
+ _TOOL_OPENERS = ("<tool_call>", "<function=")
111
+ _TAGS = ("<think>", "</think>", "<tool_call>", "</tool_call>", "<function=", "</function>")
112
+
113
+
114
+ def _safe_cut(text: str) -> str:
115
+ """Hold back a trailing fragment that might be the start of a tag we filter,
116
+ so we never emit a half-formed '<thi' or '</tool_c' to the screen."""
117
+ i = text.rfind("<")
118
+ if i == -1:
119
+ return text
120
+ tail = text[i:]
121
+ if any(tag != tail and tag.startswith(tail) for tag in _TAGS):
122
+ return text[:i]
123
+ return text
124
+
125
+
126
+ def _split_view(raw: str, final: bool, started_in_think: bool = False):
127
+ """Return (prose, think) for the raw text so far. Reasoning becomes think text;
128
+ tool-call syntax is dropped from prose entirely.
129
+
130
+ With thinking enabled the chat template opens <think> in the prompt, so the
131
+ stream starts inside reasoning and only emits the closing </think> — there is
132
+ no opening tag to match. `started_in_think` handles that: the leading text up
133
+ to the first </think> is reasoning."""
134
+ think_parts = []
135
+ s = raw
136
+ if started_in_think:
137
+ close = s.find("</think>")
138
+ if close == -1: # still inside the opening reasoning block
139
+ return "", (s if final else _safe_cut(s))
140
+ think_parts.append(s[:close])
141
+ s = s[close + len("</think>"):]
142
+ # `.append(...) or ""` is intentional: append returns None, so the replacement is
143
+ # always "" while capturing the matched group as a side effect.
144
+ s = re.sub(r"<think>(.*?)</think>",
145
+ lambda m: think_parts.append(m.group(1)) or "", # type: ignore[func-returns-value]
146
+ s, flags=re.DOTALL)
147
+ if "<think>" in s: # reasoning still open: everything after the tag is current thought
148
+ pre, _, post = s.partition("<think>")
149
+ think_parts.append(post)
150
+ s = pre
151
+ think = "".join(think_parts)
152
+
153
+ s = re.sub(r"<tool_call>.*?</tool_call>", "", s, flags=re.DOTALL)
154
+ s = re.sub(r"<function=.*?</function>", "", s, flags=re.DOTALL)
155
+ for opener in _TOOL_OPENERS: # a tool call still being written: hide from its start
156
+ i = s.find(opener)
157
+ if i != -1:
158
+ s = s[:i]
159
+ prose = s
160
+ if not final:
161
+ prose, think = _safe_cut(prose), _safe_cut(think)
162
+ return prose, think
163
+
164
+
165
+ class _StreamView:
166
+ """Feeds raw tokens in, emits 'stream' (prose) and 'think' (reasoning) deltas."""
167
+
168
+ def __init__(self, emit, started_in_think: bool = False):
169
+ self._emit = emit
170
+ self._started_in_think = started_in_think
171
+ self.raw = ""
172
+ self._prose = 0 # chars of prose already emitted
173
+ self._think = 0 # chars of reasoning already emitted
174
+
175
+ def feed(self, t: str):
176
+ self.raw += t
177
+ self._update(final=False)
178
+
179
+ def close(self):
180
+ self._update(final=True)
181
+
182
+ @property
183
+ def saw_prose(self) -> bool:
184
+ return self._prose > 0
185
+
186
+ def _update(self, final: bool):
187
+ prose, think = _split_view(self.raw, final, self._started_in_think)
188
+ if len(think) > self._think:
189
+ self._emit("think", think[self._think:])
190
+ self._think = len(think)
191
+ if len(prose) > self._prose:
192
+ self._emit("stream", prose[self._prose:])
193
+ self._prose = len(prose)
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Tool activity rendering (Claude-style): a verb + target on one line, then a
198
+ # compact summary — line counts for reads, a +/- diff for edits, a few lines of
199
+ # output for bash. Never the whole file. start() is emitted before the tool runs
200
+ # (so the spinner can show the right verb during a slow command); result() after.
201
+ # ---------------------------------------------------------------------------
202
+
203
+ _VERB = {"read": "Read", "edit": "Edit", "write": "Write", "bash": "Run",
204
+ "grep": "Search", "glob": "Find", "write_todos": "Plan",
205
+ "repo_map": "Mapping", "task": "Task"}
206
+
207
+
208
+ def _disp_path(p) -> str:
209
+ try:
210
+ r = os.path.relpath(str(p))
211
+ return r if not r.startswith("../../") else str(p)
212
+ except (ValueError, TypeError):
213
+ return str(p)
214
+
215
+
216
+ def _oneline(s: str, n: int = 72) -> str:
217
+ s = " ".join(str(s).split())
218
+ return s if len(s) <= n else s[: n - 1] + "…"
219
+
220
+
221
+ def _firstline(s: str) -> str:
222
+ s = str(s).strip().strip("[]").strip()
223
+ return s.splitlines()[0] if s else ""
224
+
225
+
226
+ def _nlines(s: str) -> int:
227
+ s = str(s)
228
+ return 0 if not s else s.count("\n") + (0 if s.endswith("\n") else 1)
229
+
230
+
231
+ def _is_err(result: str) -> bool:
232
+ # Display-only heuristic: does this tool result read as a chad error message? Those
233
+ # are single bracketed diagnostics (`[no such file: …]`, `[exit 1]\n…`), so require a
234
+ # leading `[` AND scan only the FIRST line (plan 044 item 7) — otherwise a `read`/grep
235
+ # of `[`-leading multi-line content (a JSON array, a TOML/markdown doc) would smuggle a
236
+ # keyword in from a later line and get mis-styled as an error.
237
+ r = str(result)
238
+ if not r.startswith("["):
239
+ return False
240
+ head = r.split("\n", 1)[0][:48].lower()
241
+ return any(k in head for k in (
242
+ "no such file", "cannot read", "error", "not found", "denied", "bad regex",
243
+ "timed out", "exit ", "must be", "missing", "unknown tool", "no-op", "appears",
244
+ "old string", "empty"))
245
+
246
+
247
+ def _indent_block(emit, text: str, kind: str = "muted", max_lines: int = 6):
248
+ text = str(text).rstrip("\n")
249
+ if not text:
250
+ emit("muted", " ⎿ (no output)")
251
+ return
252
+ lines = text.split("\n")
253
+ for i, ln in enumerate(lines[:max_lines]):
254
+ emit(kind, (" ⎿ " if i == 0 else " ") + ln)
255
+ if len(lines) > max_lines:
256
+ emit("muted", f" … +{len(lines) - max_lines} lines")
257
+
258
+
259
+ def _emit_diff(emit, old: str, new: str, max_lines: int = 30, filename: str = ""):
260
+ diff = [d for d in difflib.unified_diff(
261
+ str(old).splitlines(), str(new).splitlines(), lineterm="", n=2)
262
+ if not d.startswith(("---", "+++", "@@"))]
263
+ adds = sum(1 for d in diff if d.startswith("+"))
264
+ dels = sum(1 for d in diff if d.startswith("-"))
265
+ emit("muted", f" ⎿ +{adds} -{dels}")
266
+ for d in diff[:max_lines]:
267
+ # Syntax colors live *inside* the +/- line color (the +/- kind is the outer
268
+ # layer). `_highlight_code` is a no-op without pygments, so the plain path is
269
+ # byte-identical to the pre-042 output.
270
+ if d.startswith("+"):
271
+ emit("add", " + " + _highlight_code(d[1:], filename))
272
+ elif d.startswith("-"):
273
+ emit("del", " - " + _highlight_code(d[1:], filename))
274
+ else:
275
+ emit("muted", " " + _highlight_code(d[1:], filename))
276
+ if len(diff) > max_lines:
277
+ emit("muted", f" … +{len(diff) - max_lines} more diff lines")
278
+
279
+
280
+ def render_tool_start(emit, name: str, args: dict):
281
+ """One-line header shown before the tool executes."""
282
+ if name == "read":
283
+ emit("tool", f"Read {_disp_path(args.get('path', ''))}")
284
+ elif name in ("edit", "write"):
285
+ emit("tool", f"{_VERB[name]} {_disp_path(args.get('path', ''))}")
286
+ elif name == "bash":
287
+ emit("tool", f"Run {_oneline(args.get('command', ''))}")
288
+ elif name == "grep":
289
+ emit("tool", f"Search {_oneline(args.get('pattern', ''), 50)!r}")
290
+ elif name == "glob":
291
+ emit("tool", f"Find {_oneline(args.get('pattern', ''), 50)}")
292
+ elif name == "write_todos":
293
+ emit("tool", "Plan")
294
+ elif name == "task":
295
+ emit("tool", f"Task {_oneline(args.get('description', ''), 50)}")
296
+ elif name == "overview":
297
+ emit("tool", f"Overview {_disp_path(args.get('path', ''))}")
298
+ elif name == "view_symbol":
299
+ emit("tool", f"View {args.get('name', '')}")
300
+ elif name == "find_symbol":
301
+ emit("tool", f"Find {args.get('name', '')}")
302
+ elif name == "find_refs":
303
+ emit("tool", f"Refs {args.get('name', '')}")
304
+ elif name in ("replace_symbol", "insert_symbol"):
305
+ emit("tool", f"Edit {args.get('name', '')}")
306
+ elif name == "rename_symbol":
307
+ emit("tool", f"Rename {args.get('name', '')} → {args.get('new_name', '')}")
308
+ elif name.startswith("mcp__"):
309
+ # mcp__<server>__<tool> -> "MCP server/tool {args…}" so a trace reads cleanly.
310
+ bits = name[len("mcp__"):].split("__", 1)
311
+ loc = " / ".join(bits) if len(bits) == 2 else name
312
+ emit("tool", f"MCP {loc} {_oneline(_compact_args(args), 60)}")
313
+ else:
314
+ emit("tool", name)
315
+
316
+
317
+ def render_tool_result(emit, name: str, args: dict, result: str):
318
+ """Compact summary shown after the tool returns."""
319
+ result = str(result)
320
+ # Symbolic edits: result is "[summary]\n<unified diff>" on success.
321
+ if name in ("replace_symbol", "insert_symbol"):
322
+ if result.startswith(("[replaced", "[inserted")):
323
+ head, _, body = result.partition("\n")
324
+ emit("muted", " ⎿ " + _firstline(head))
325
+ for d in body.split("\n")[:40]:
326
+ if d.startswith("+"):
327
+ emit("add", " + " + d[1:])
328
+ elif d.startswith("-"):
329
+ emit("del", " - " + d[1:])
330
+ elif d:
331
+ emit("muted", " " + d)
332
+ else:
333
+ emit("error", " ⎿ " + _firstline(result))
334
+ return
335
+ if name == "rename_symbol":
336
+ emit("muted" if result.startswith("[renamed") else "error",
337
+ " ⎿ " + _firstline(result))
338
+ return
339
+ if _is_err(result):
340
+ emit("error", " ⎿ " + _firstline(result))
341
+ return
342
+ if name == "read":
343
+ n = _nlines(result)
344
+ emit("muted", f" ⎿ {n} line{'s' * (n != 1)}")
345
+ elif name == "view_symbol":
346
+ n = _nlines(result)
347
+ emit("muted", f" ⎿ {n} line{'s' * (n != 1)}")
348
+ elif name == "overview":
349
+ n = _nlines(result)
350
+ emit("muted", f" ⎿ {n} symbol{'s' * (n != 1)}")
351
+ elif name == "find_symbol":
352
+ n = _nlines(result)
353
+ emit("muted", f" ⎿ {n} definition{'s' * (n != 1)}")
354
+ elif name == "find_refs":
355
+ n = _nlines(result)
356
+ emit("muted", f" ⎿ {n} reference{'s' * (n != 1)}")
357
+ elif name == "edit":
358
+ _emit_diff(emit, args.get("old", ""), args.get("new", ""),
359
+ filename=str(args.get("path", "")))
360
+ elif name == "write":
361
+ content = args.get("content", "")
362
+ emit("muted", f" ⎿ {_nlines(content)} lines written")
363
+ _emit_diff(emit, "", content, max_lines=12, filename=str(args.get("path", "")))
364
+ elif name == "bash":
365
+ _indent_block(emit, result)
366
+ elif name == "task":
367
+ n = _nlines(result)
368
+ emit("muted", f" ⎿ sub-agent returned {n} line{'s' * (n != 1)}")
369
+ _indent_block(emit, result, max_lines=4)
370
+ elif name == "grep":
371
+ lines = [l for l in result.splitlines() if ":" in l]
372
+ files = len({l.split(":", 1)[0] for l in lines})
373
+ n = len(lines)
374
+ emit("muted", f" ⎿ {n} match{'es' * (n != 1)} in {files} file{'s' * (files != 1)}")
375
+ elif name == "glob":
376
+ n = _nlines(result)
377
+ emit("muted", f" ⎿ {n} file{'s' * (n != 1)}")
378
+ elif name == "write_todos":
379
+ for line in result.splitlines()[1:]: # drop the "Plan updated:" header
380
+ emit("muted", " " + line.strip())
381
+ # Also feed the structured list to the TUI's pinned todo panel (plan 042 item 1).
382
+ # The plain REPL / one-shot emitter drops the `todos` kind (like ctx/gen/prefill),
383
+ # so the inline muted lines above remain its only rendering.
384
+ todos = args.get("todos")
385
+ if isinstance(todos, list):
386
+ emit("todos", json.dumps(todos))
387
+ else:
388
+ _indent_block(emit, result)
389
+
390
+
391
+ def ansi_fragment(kind: str, text: str) -> str | None:
392
+ """The transcript ANSI fragment shared by the REPL emitter and the TUI. Returns None
393
+ for kinds a caller renders specially (stream, user) or drops (gauges/unknowns)."""
394
+ if kind == "think":
395
+ return C_DIM + text + C_RST
396
+ if kind == "tool":
397
+ return f"\n{C_GREEN}●{C_RST} {C_BOLD}{text}{C_RST}\n"
398
+ if kind == "add":
399
+ return f"{C_GREEN}{text}{C_RST}\n"
400
+ if kind == "del":
401
+ return f"{C_RED}{text}{C_RST}\n"
402
+ if kind == "error":
403
+ return f"{C_YEL}{text}{C_RST}\n"
404
+ if kind in ("info", "muted"):
405
+ return f"{C_DIM}{text}{C_RST}\n"
406
+ return None
407
+
408
+
409
+ # chad's moai (🗿) mascot rendered as a bone-club silhouette — the startup banner art.
410
+ # Three rows so it sits beside three info lines (name/version, model, cwd), Claude-Code
411
+ # style. Kept as a module constant so tests can assert on it without a live engine.
412
+ _BANNER_ART = ("▟█▙▂▂▂", "▜█▛▔▔▔", "▘ ▝ ")
413
+
414
+
415
+ def _tilde(path: str) -> str:
416
+ """Collapse the home-directory prefix to ~, like a shell prompt."""
417
+ home = os.path.expanduser("~")
418
+ if path == home:
419
+ return "~"
420
+ if path.startswith(home + os.sep):
421
+ return "~" + path[len(home):]
422
+ return path
423
+
424
+
425
+ def banner(model: str, ctx_limit: int | None, mode: str = "normal",
426
+ version: str | None = None, cwd: str | None = None) -> str:
427
+ """The startup banner: bone-club art on the left, live session info on the right.
428
+
429
+ Mirrors Claude Code's header — name+version, model+context, and the working
430
+ directory — so a fresh session states what it is at a glance. Returns a plain
431
+ multi-line ANSI string (no trailing newline); the caller emits it verbatim."""
432
+ if version is None:
433
+ from . import __version__ as version
434
+ if cwd is None:
435
+ cwd = os.getcwd()
436
+ ctx = f"{ctx_limit / 1000:.0f}k context" if ctx_limit else "context tbd"
437
+ info = [
438
+ f"{C_BOLD}chad{C_RST} {C_DIM}v{version}{C_RST}",
439
+ f"{model} {C_DIM}· {ctx} · {mode} mode{C_RST}",
440
+ f"{C_DIM}{_tilde(cwd)}{C_RST}",
441
+ ]
442
+ width = max(len(a) for a in _BANNER_ART)
443
+ rows = [f"{C_YEL}{art:<{width}}{C_RST} {text}"
444
+ for art, text in zip(_BANNER_ART, info)]
445
+ return "\n".join(rows)
446
+
447
+
448
+ def _default_emit(kind: str, text: str):
449
+ """Default emitter: colored stdout, used by the plain REPL and one-shot mode."""
450
+ w = sys.stdout.write
451
+ if kind == "stream": # DIVERGES from the TUI: green-wrapped here, raw there.
452
+ w(C_GREEN + text + C_RST)
453
+ elif kind == "user": # DIVERGES from the TUI: single-line here, multi-line there.
454
+ w(f"\n{C_YEL}» {text}{C_RST}\n")
455
+ else:
456
+ frag = ansi_fragment(kind, text)
457
+ if frag is not None:
458
+ w(frag)
459
+ # 'stat', the live-gauge kinds (ctx/gen/prefill/status), the pinned 'todos' panel
460
+ # feed, and any unknown kinds return None and are intentionally dropped from
461
+ # stdout — they belong to the TUI's pinned region, not the plain REPL scrollback.
462
+ sys.stdout.flush()