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/__init__.py +7 -0
- chad/agent.py +1093 -0
- chad/base_engine.py +109 -0
- chad/bench.py +243 -0
- chad/cli.py +469 -0
- chad/compaction.py +108 -0
- chad/config.py +63 -0
- chad/diag.py +100 -0
- chad/engine.py +883 -0
- chad/guardrails.py +373 -0
- chad/ignore.py +18 -0
- chad/lsp.py +323 -0
- chad/mcp.py +719 -0
- chad/mcp_oauth.py +329 -0
- chad/openai_engine.py +252 -0
- chad/prompt.py +305 -0
- chad/render.py +462 -0
- chad/repomap.py +767 -0
- chad/session.py +281 -0
- chad/skills.py +407 -0
- chad/symbols.py +360 -0
- chad/syntaxgate.py +105 -0
- chad/toolcall_parse.py +115 -0
- chad/tools.py +962 -0
- chad/tui.py +921 -0
- chad/validate.py +361 -0
- chad_code-0.1.0.dist-info/METADATA +370 -0
- chad_code-0.1.0.dist-info/RECORD +32 -0
- chad_code-0.1.0.dist-info/WHEEL +5 -0
- chad_code-0.1.0.dist-info/entry_points.txt +4 -0
- chad_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- chad_code-0.1.0.dist-info/top_level.txt +1 -0
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()
|