aether-code 0.42.0 → 0.43.1

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.
package/src/menu.js CHANGED
@@ -1,83 +1,83 @@
1
- // Interactive multiple-choice menu — arrow/number selectable, cmd.exe-safe.
2
- //
3
- // Deliberately NOT Ink (which ghosts in the legacy Windows console). Uses a
4
- // simple manual redraw: print the list, and on each keypress move the cursor
5
- // back up to the top of the menu (ESC[nA) and clear-to-end (ESC[J), then
6
- // reprint. That's the same VT layer that already renders our ANSI colours, so
7
- // it works in cmd.exe, Windows Terminal, and POSIX terminals alike.
8
- //
9
- // Returns the chosen option object ({label, description}), or null if cancelled.
10
-
11
- import readline from "node:readline";
12
- import { c } from "./render.js";
13
-
14
- const ellip = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + "…" : s || "");
15
-
16
- export function promptChoice({ question, options }) {
17
- const opts = (options || [])
18
- .map((o) => (typeof o === "string" ? { label: o } : o))
19
- .filter((o) => o && o.label);
20
-
21
- return new Promise((resolve) => {
22
- // Non-interactive (piped / CI): pick the first option so automation never
23
- // hangs waiting on a keypress that can't come. Print a note so it's visible.
24
- if (!process.stdin.isTTY || opts.length === 0) {
25
- const pick = opts[0] ?? null;
26
- if (pick) process.stdout.write(c.cyan("● ") + c.bold(question) + c.gray(` → ${pick.label} (auto)\n`));
27
- resolve(pick);
28
- return;
29
- }
30
-
31
- let sel = 0;
32
- const total = opts.length + 3; // question line + options + blank + hint line
33
-
34
- const draw = (first) => {
35
- if (!first) process.stdout.write(`\x1b[${total}A`); // cursor up to menu top
36
- process.stdout.write("\x1b[J"); // clear from cursor to end of screen
37
- process.stdout.write(c.cyan("● ") + c.bold(question) + "\n");
38
- opts.forEach((o, i) => {
39
- const active = i === sel;
40
- const pointer = active ? c.cyan("→") : " ";
41
- const num = active ? c.cyan(c.bold(`${i + 1}.`)) : c.gray(`${i + 1}.`);
42
- const label = active ? c.bold(o.label) : o.label;
43
- const desc = o.description ? c.gray(" " + ellip(o.description, 60)) : "";
44
- process.stdout.write(` ${pointer} ${num} ${label}${desc}\n`);
45
- });
46
- process.stdout.write("\n" + c.gray(" ↑/↓ move · 1-9 pick · Enter select · Esc cancel") + "\n");
47
- };
48
-
49
- const cleanup = () => {
50
- process.stdin.removeListener("keypress", onKey);
51
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
52
- process.stdin.pause();
53
- };
54
-
55
- const finish = (result) => {
56
- cleanup();
57
- // Collapse the menu to a compact answer line so the transcript stays clean.
58
- process.stdout.write(`\x1b[${total}A\x1b[J`);
59
- process.stdout.write(c.cyan("● ") + c.bold(question) + "\n");
60
- if (result) process.stdout.write(" " + c.cyan("→") + " " + c.green(result.label) + "\n\n");
61
- else process.stdout.write(" " + c.gray("(cancelled — proceeding with best judgment)") + "\n\n");
62
- resolve(result);
63
- };
64
-
65
- const onKey = (str, key) => {
66
- if (!key) return;
67
- if (key.name === "up" || key.name === "k") { sel = (sel - 1 + opts.length) % opts.length; draw(false); }
68
- else if (key.name === "down" || key.name === "j" || key.name === "tab") { sel = (sel + 1) % opts.length; draw(false); }
69
- else if (str && /^[1-9]$/.test(str)) {
70
- const i = parseInt(str, 10) - 1;
71
- if (i < opts.length) finish(opts[i]);
72
- }
73
- else if (key.name === "return") finish(opts[sel]);
74
- else if (key.name === "escape" || (key.ctrl && key.name === "c")) finish(null);
75
- };
76
-
77
- readline.emitKeypressEvents(process.stdin);
78
- if (process.stdin.isTTY) process.stdin.setRawMode(true);
79
- process.stdin.resume();
80
- process.stdin.on("keypress", onKey);
81
- draw(true);
82
- });
83
- }
1
+ // Interactive multiple-choice menu — arrow/number selectable, cmd.exe-safe.
2
+ //
3
+ // Deliberately NOT Ink (which ghosts in the legacy Windows console). Uses a
4
+ // simple manual redraw: print the list, and on each keypress move the cursor
5
+ // back up to the top of the menu (ESC[nA) and clear-to-end (ESC[J), then
6
+ // reprint. That's the same VT layer that already renders our ANSI colours, so
7
+ // it works in cmd.exe, Windows Terminal, and POSIX terminals alike.
8
+ //
9
+ // Returns the chosen option object ({label, description}), or null if cancelled.
10
+
11
+ import readline from "node:readline";
12
+ import { c } from "./render.js";
13
+
14
+ const ellip = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + "…" : s || "");
15
+
16
+ export function promptChoice({ question, options }) {
17
+ const opts = (options || [])
18
+ .map((o) => (typeof o === "string" ? { label: o } : o))
19
+ .filter((o) => o && o.label);
20
+
21
+ return new Promise((resolve) => {
22
+ // Non-interactive (piped / CI): pick the first option so automation never
23
+ // hangs waiting on a keypress that can't come. Print a note so it's visible.
24
+ if (!process.stdin.isTTY || opts.length === 0) {
25
+ const pick = opts[0] ?? null;
26
+ if (pick) process.stdout.write(c.cyan("● ") + c.bold(question) + c.gray(` → ${pick.label} (auto)\n`));
27
+ resolve(pick);
28
+ return;
29
+ }
30
+
31
+ let sel = 0;
32
+ const total = opts.length + 3; // question line + options + blank + hint line
33
+
34
+ const draw = (first) => {
35
+ if (!first) process.stdout.write(`\x1b[${total}A`); // cursor up to menu top
36
+ process.stdout.write("\x1b[J"); // clear from cursor to end of screen
37
+ process.stdout.write(c.cyan("● ") + c.bold(question) + "\n");
38
+ opts.forEach((o, i) => {
39
+ const active = i === sel;
40
+ const pointer = active ? c.cyan("→") : " ";
41
+ const num = active ? c.cyan(c.bold(`${i + 1}.`)) : c.gray(`${i + 1}.`);
42
+ const label = active ? c.bold(o.label) : o.label;
43
+ const desc = o.description ? c.gray(" " + ellip(o.description, 60)) : "";
44
+ process.stdout.write(` ${pointer} ${num} ${label}${desc}\n`);
45
+ });
46
+ process.stdout.write("\n" + c.gray(" ↑/↓ move · 1-9 pick · Enter select · Esc cancel") + "\n");
47
+ };
48
+
49
+ const cleanup = () => {
50
+ process.stdin.removeListener("keypress", onKey);
51
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
52
+ process.stdin.pause();
53
+ };
54
+
55
+ const finish = (result) => {
56
+ cleanup();
57
+ // Collapse the menu to a compact answer line so the transcript stays clean.
58
+ process.stdout.write(`\x1b[${total}A\x1b[J`);
59
+ process.stdout.write(c.cyan("● ") + c.bold(question) + "\n");
60
+ if (result) process.stdout.write(" " + c.cyan("→") + " " + c.green(result.label) + "\n\n");
61
+ else process.stdout.write(" " + c.gray("(cancelled — proceeding with best judgment)") + "\n\n");
62
+ resolve(result);
63
+ };
64
+
65
+ const onKey = (str, key) => {
66
+ if (!key) return;
67
+ if (key.name === "up" || key.name === "k") { sel = (sel - 1 + opts.length) % opts.length; draw(false); }
68
+ else if (key.name === "down" || key.name === "j" || key.name === "tab") { sel = (sel + 1) % opts.length; draw(false); }
69
+ else if (str && /^[1-9]$/.test(str)) {
70
+ const i = parseInt(str, 10) - 1;
71
+ if (i < opts.length) finish(opts[i]);
72
+ }
73
+ else if (key.name === "return") finish(opts[sel]);
74
+ else if (key.name === "escape" || (key.ctrl && key.name === "c")) finish(null);
75
+ };
76
+
77
+ readline.emitKeypressEvents(process.stdin);
78
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
79
+ process.stdin.resume();
80
+ process.stdin.on("keypress", onKey);
81
+ draw(true);
82
+ });
83
+ }
package/src/render.js CHANGED
@@ -26,8 +26,6 @@ export function divider() {
26
26
 
27
27
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
28
28
 
29
- // Draw a bordered box around content lines, returning the rows as an array (so
30
- // boxes can be composed side by side). Rounded box-drawing chars.
31
29
  export function boxLines(lines, { color = c.gray, padX = 2, minInner = 0 } = {}) {
32
30
  const inner = Math.max(minInner, 0, ...lines.map((l) => stripAnsi(l).length));
33
31
  const w = inner + padX * 2;
@@ -43,8 +41,6 @@ export function box(lines, opts) {
43
41
  return boxLines(lines, opts).join("\n");
44
42
  }
45
43
 
46
- // Join two blocks of lines horizontally with a gap, padding the left block to a
47
- // uniform width so the right block aligns. Shorter block is padded with blanks.
48
44
  export function sideBySide(a, b, gap = 3) {
49
45
  const wa = Math.max(0, ...a.map((l) => stripAnsi(l).length));
50
46
  const h = Math.max(a.length, b.length);
@@ -62,7 +58,6 @@ export function turn(n) {
62
58
  }
63
59
 
64
60
  export function toolHeader(name, args) {
65
- // Format args compactly. If any value is huge, truncate it.
66
61
  const compact = JSON.stringify(args);
67
62
  const trimmed = compact.length > 120 ? compact.slice(0, 117) + "..." : compact;
68
63
  return `${c.cyan(c.bold(name))}${c.gray("(")}${c.gray(trimmed)}${c.gray(")")}`;
@@ -70,114 +65,185 @@ export function toolHeader(name, args) {
70
65
 
71
66
  const ellip = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + "…" : s || "");
72
67
 
73
- // Clean one-line label for a tool call — a verb + its key argument, instead of
74
- // dumping raw JSON (which buried file contents / queries in noise).
75
68
  export function toolLabel(name, args) {
76
69
  const a = args || {};
77
70
  const verb = (v) => c.cyan(c.bold(v));
78
- const arg = (s) => c.gray(ellip(String(s), 72));
71
+ const arg = (s) => c.gray(ellip(s == null ? "" : String(s), 72));
79
72
  switch (name) {
80
- case "read_file": return `${verb("read")} ${arg(a.path)}`;
81
- case "write_file": return `${verb("write")} ${arg(a.path)}`;
82
- case "edit_file": return `${verb("edit")} ${arg(a.path)}`;
83
- case "list_dir": return `${verb("list")} ${arg(a.path)}`;
84
- case "glob_files": return `${verb("find")} ${arg(a.pattern)}`;
85
- case "search_files": return `${verb("search")} ${arg(`/${a.pattern}/ in ${a.path ?? "."}`)}`;
86
- case "run_shell": return `${verb("run")} ${arg(a.command)}`;
87
- case "web_search": return `${verb("search web")} ${arg(JSON.stringify(a.query ?? ""))}`;
88
- case "web_fetch": return `${verb("fetch")} ${arg(a.url)}`;
89
- case "todo_write": return ""; // its Plan box is the label
90
- case "ask_user": return ""; // the interactive menu is its display
91
- case "git_status": return `${verb("git")} ${arg("status")}`;
92
- case "git_diff": return `${verb("git")} ${arg("diff" + (a.staged ? " --staged" : "") + (a.base ? ` ${a.base}...${a.head || "HEAD"}` : ""))}`;
93
- case "git_log": return `${verb("git")} ${arg("log" + (a.file ? ` -- ${a.file}` : "") + (a.branch ? ` ${a.branch}` : ""))}`;
94
- case "git_commit": return `${verb("git")} ${arg(`commit "${ellip(a.message || "", 50)}"`)}`
95
- case "git_branch": return `${verb("git")} ${arg(`branch ${a.name}`)}`;
96
- case "git_push": return `${verb("git")} ${arg("push" + (a.force ? " --force" : ""))}`;
97
- case "git_create_pr": return `${verb("git")} ${arg(`pr "${ellip(a.title || "", 50)}"`)}`
73
+ case "read_file": return `${verb("Read")} ${arg(a.path)}`;
74
+ case "write_file": return `${verb("Write")} ${arg(a.path)}`;
75
+ case "edit_file": return `${verb("Edit")} ${arg(a.path)}`;
76
+ case "list_dir": return `${verb("List")} ${arg(a.path)}`;
77
+ case "glob_files": return `${verb("Glob")} ${arg(a.pattern)}`;
78
+ case "search_files": return `${verb("Grep")} ${arg(`/${a.pattern}/ in ${a.path ?? "."}`)}`;
79
+ case "run_shell": return `${verb("Bash")} ${arg(a.command)}`;
80
+ case "web_search": return `${verb("WebSearch")} ${arg(a.query ?? "")}`;
81
+ case "web_fetch": return `${verb("WebFetch")} ${arg(a.url)}`;
82
+ case "todo_write": return "";
83
+ case "ask_user": return "";
98
84
  default: return `${verb(name)} ${arg(JSON.stringify(a))}`;
99
85
  }
100
86
  }
101
87
 
102
- // Set the terminal window/tab title (so cmd.exe shows "Aether", not the node
103
- // path). OSC 0 — supported by cmd.exe, Windows Terminal, and POSIX terminals.
104
88
  export function setTerminalTitle(title) {
105
89
  if (process.stdout.isTTY) process.stdout.write(`\x1b]0;${title}\x07`);
106
90
  }
107
91
 
108
- // Live "working…" spinner. Rewrites a single line in place (\r) with a rotating
109
- // half-circle (Geometric Shapes — same block as ●, so cmd.exe-safe) + elapsed
110
- // seconds, so the user sees the agent is alive while the model thinks or a slow
111
- // tool runs. stop() clears the line and restores the cursor. No-op in non-TTY.
92
+ // Fun thinking words — like Claude Code's "Hullaballooing...", "Cogitating..."
93
+ const THINKING_WORDS = [
94
+ "Thinking",
95
+ "Pondering",
96
+ "Mulling",
97
+ "Cogitating",
98
+ "Ruminating",
99
+ "Deliberating",
100
+ "Contemplating",
101
+ "Noodling",
102
+ "Musing",
103
+ "Brainstorming",
104
+ "Percolating",
105
+ "Brainwaving",
106
+ "Daydreaming",
107
+ "Hullaballooing",
108
+ "Churning",
109
+ "Brewing",
110
+ "Conjuring",
111
+ "Scheming",
112
+ "Hatching",
113
+ "Concocting",
114
+ "Simmering",
115
+ "Marinating",
116
+ "Fermenting",
117
+ "Stewing",
118
+ "Whittling",
119
+ "Tinkering",
120
+ "Puzzling",
121
+ "Deciphering",
122
+ "Unraveling",
123
+ "Distilling",
124
+ ];
125
+
126
+ // Pick a random thinking word (seeded by current second so it varies per call)
127
+ function pickThinkingWord() {
128
+ const idx = (Date.now() % (THINKING_WORDS.length * 1000)) / 1000 | 0;
129
+ return THINKING_WORDS[idx % THINKING_WORDS.length];
130
+ }
131
+
112
132
  export function startSpinner(label) {
113
- if (!process.stdout.isTTY) return { stop: () => {} };
114
- const frames = ["◐", "◓", "◑", "◒"];
133
+ if (!process.stdout.isTTY) return { stop: () => {}, update: () => {} };
134
+ // Ping-pong animation — cmd.exe-safe chars inspired by Claude Code's sparkle cycle
135
+ const frames = ["\xB7", "*", "+", "*", "\xB7", ".", "\xB7", "*", "+", "*", "\xB7", "."];
115
136
  const t0 = Date.now();
116
137
  let i = 0;
117
- process.stdout.write("\x1b[?25l"); // hide cursor
138
+ let tokenCount = 0;
139
+ let statusText = "";
140
+ const isThinking = label === "thinking";
141
+ const thinkWord = isThinking ? pickThinkingWord() : null;
142
+ process.stdout.write("\x1b[?25l");
118
143
  const render = () => {
119
- const s = Math.floor((Date.now() - t0) / 1000);
120
- const tail = s > 0 ? c.gray(` ${s}s`) : "";
121
- process.stdout.write(`\r\x1b[K${c.cyan(frames[i % frames.length])} ${c.gray(label + "…")}${tail}`);
144
+ const ms = Date.now() - t0;
145
+ const secs = Math.floor(ms / 1000);
146
+ const timeFmt = secs >= 60
147
+ ? `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, "0")}s`
148
+ : `${secs}s`;
149
+ const parts = [];
150
+ if (secs > 0) parts.push(timeFmt);
151
+ if (tokenCount > 0) parts.push(`${fmtTokens(tokenCount)} tokens`);
152
+ if (statusText) parts.push(statusText);
153
+ const metrics = parts.length > 0 ? c.gray(` (${parts.join(" \xB7 ")})`) : "";
154
+ const displayLabel = isThinking ? thinkWord : label;
155
+ process.stdout.write(`\r\x1b[K${c.cyan(frames[i % frames.length])} ${c.dim(displayLabel + "...")}${metrics}`);
122
156
  i++;
123
157
  };
124
158
  render();
125
159
  const timer = setInterval(render, 120);
126
- if (typeof timer.unref === "function") timer.unref(); // don't keep the process alive
160
+ if (typeof timer.unref === "function") timer.unref();
127
161
  return {
128
- stop: () => {
162
+ update(data) {
163
+ if (data.tokens != null) tokenCount = data.tokens;
164
+ if (data.status != null) statusText = data.status;
165
+ },
166
+ stop() {
129
167
  clearInterval(timer);
130
- process.stdout.write("\r\x1b[K\x1b[?25h"); // clear line + show cursor
168
+ process.stdout.write("\r\x1b[K\x1b[?25h");
131
169
  },
132
170
  };
133
171
  }
134
172
 
135
- // Terse one-line result summary instead of dumping raw JSON / file contents.
136
- // Tools whose handlers already render rich output (diffs, the shell stream, the
137
- // plan) just get a check — the detail was already printed.
173
+ export function fmtTokens(n) {
174
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
175
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
176
+ return String(n);
177
+ }
178
+
179
+ export function turnStats({ elapsed, tokensIn, tokensOut, tools, credits }) {
180
+ const parts = [];
181
+ if (elapsed > 0) {
182
+ const s = Math.floor(elapsed / 1000);
183
+ parts.push(s >= 60 ? `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s` : `${s}s`);
184
+ }
185
+ if (tokensIn > 0 || tokensOut > 0) {
186
+ parts.push(`${fmtTokens(tokensIn)}->${fmtTokens(tokensOut)} tokens`);
187
+ }
188
+ if (tools > 0) parts.push(`${tools} tool call${tools === 1 ? "" : "s"}`);
189
+ if (credits > 0) parts.push(`${credits} credit${credits === 1 ? "" : "s"}`);
190
+ return parts.length > 0 ? c.dim(` ${parts.join(" \xB7 ")}`) : "";
191
+ }
192
+
193
+ export function toolResult(text, ok = true) {
194
+ const prefix = ok ? c.green(" ● ") : c.red(" × ");
195
+ const lines = text.split("\n");
196
+ const head = lines[0].slice(0, 200);
197
+ const rest = lines.slice(1, 6).join("\n").slice(0, 600);
198
+ return `${prefix}${head}${rest ? "\n" + c.dim(rest) : ""}`;
199
+ }
200
+
201
+ export function assistant(text) {
202
+ return text.split("\n").map((l) => ` ${l}`).join("\n");
203
+ }
204
+
205
+ export function errorLine(msg) {
206
+ return `${c.red(c.bold("Error:"))} ${msg}`;
207
+ }
208
+
209
+ export function note(msg) {
210
+ return c.dim(msg);
211
+ }
212
+
138
213
  export function toolSummary(name, result) {
139
214
  const ok = result.ok;
140
- // A tree branch links the result to the action above it (modern CLI style).
141
215
  const branch = ok ? c.green("└─") : c.red("└─");
142
216
  const out = result.output ?? "";
143
217
  const firstLine = out.split("\n").find((l) => l.trim()) ?? "";
144
218
 
145
219
  if (name === "run_shell") {
146
220
  let code = null;
147
- try { code = JSON.parse(out).exit_code; } catch { /* ignore */ }
148
- return ` ${branch} ${c.gray(code === null ? (ok ? "done" : "failed") : `exit ${code}`)}`;
149
- }
150
- if (name === "todo_write") return ""; // the Plan is its own feedback
151
- if (name === "ask_user") return ""; // the menu already showed the answer
152
- if (name === "git_commit" || name === "git_push" || name === "git_create_pr") {
153
- return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
154
- }
155
- if (name === "git_status") {
221
+ let stderr = "";
156
222
  try {
157
- const s = JSON.parse(out);
158
- if (s.clean) return ` ${branch} ${c.gray("clean")}`;
159
- const parts = [];
160
- if (s.staged?.length) parts.push(`${s.staged.length} staged`);
161
- if (s.unstaged?.length) parts.push(`${s.unstaged.length} modified`);
162
- if (s.untracked?.length) parts.push(`${s.untracked.length} untracked`);
163
- return ` ${branch} ${c.gray(parts.join(", "))}`;
164
- } catch { /* fall through */ }
165
- }
166
- if (name === "git_diff") {
167
- if (out === "(no changes)") return ` ${branch} ${c.gray("no changes")}`;
168
- const diffLines = out.split("\n").length;
169
- return ` ${branch} ${c.gray(`${diffLines} lines of diff`)}`;
223
+ const j = JSON.parse(out);
224
+ code = j.exit_code;
225
+ stderr = (j.stderr || "").split("\n")[0] || "";
226
+ } catch { /* ignore */ }
227
+ const status = code === null ? (ok ? "done" : "failed") : code === 0 ? "done" : `exit ${code}`;
228
+ const detail = !ok && stderr ? ` ${ellip(stderr, 60)}` : "";
229
+ return ` ${branch} ${c.gray(status + detail)}`;
170
230
  }
171
- if (name === "git_log") {
172
- const logLines = out.trim().split("\n").filter((l) => l.trim()).length;
173
- return ` ${branch} ${c.gray(`${logLines} lines`)}`;
231
+ if (name === "todo_write") return "";
232
+ if (name === "ask_user") return "";
233
+ if (name === "write_file") {
234
+ return ` ${branch} ${c.gray("wrote file")}`;
174
235
  }
175
- if (name === "git_branch") {
236
+ if (name === "edit_file") {
176
237
  return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
177
238
  }
178
- if (name === "write_file" || name === "edit_file") {
179
- // Handler already printed the diff; echo its short status line.
180
- return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
239
+ if (name === "web_search") {
240
+ let count = 0;
241
+ try { const j = JSON.parse(out); if (Array.isArray(j)) count = j.length; } catch { /* ignore */ }
242
+ return ` ${branch} ${c.gray(count > 0 ? `${count} results` : "no results")}`;
243
+ }
244
+ if (name === "web_fetch") {
245
+ const lines = out.split("\n").length;
246
+ return ` ${branch} ${c.gray(`${lines} lines fetched`)}`;
181
247
  }
182
248
  let summary = "";
183
249
  try {
@@ -192,28 +258,18 @@ export function toolSummary(name, result) {
192
258
  return ` ${branch} ${c.gray(summary)}`;
193
259
  }
194
260
 
195
- // Strip model "harmony"/channel control tokens (<|channel|>, <|message|>,
196
- // <|tool_response|>, <channel|>, …) that occasionally leak into the text
197
- // stream. Belt-and-suspenders alongside the server-side scrub.
198
- // Only strips tokens containing a PIPE — the harmony/channel control tokens
199
- // (<|channel|>, <|tool_response|>, <channel|>) always have one. Real code like
200
- // <div>, Vec<T>, a < b has no pipe and is left untouched.
201
261
  const MODEL_TOKEN_RE = /<\|[a-z_]*\|?>|<[a-z_]+\|>/gi;
202
262
 
203
263
  export function stripModelTokens(text) {
204
264
  return text.replace(MODEL_TOKEN_RE, "");
205
265
  }
206
266
 
207
- // Streaming-safe stripper: the leaked tokens (<|channel|>, <|tool_response|>, …)
208
- // can be split across stream chunks ("<chann" then "el|>"), which a per-delta
209
- // regex misses. This buffers any trailing "<…" that might be the start of a
210
- // token and only emits it once it's confirmed not-a-token (or on flush).
211
267
  export function makeTokenStripper() {
212
268
  let buf = "";
213
269
  return {
214
270
  push(text) {
215
271
  buf = (buf + text).replace(MODEL_TOKEN_RE, "");
216
- const partial = buf.match(/<[|a-z_/]*$/i); // possible token start at the tail
272
+ const partial = buf.match(/<[|a-z_/]*$/i);
217
273
  if (partial) {
218
274
  const emit = buf.slice(0, partial.index);
219
275
  buf = buf.slice(partial.index);
@@ -230,25 +286,3 @@ export function makeTokenStripper() {
230
286
  },
231
287
  };
232
288
  }
233
-
234
- export function toolResult(text, ok = true) {
235
- const prefix = ok ? c.green(" ● ") : c.red(" × ");
236
- // First line bold-ish, then dim continuation
237
- const lines = text.split("\n");
238
- const head = lines[0].slice(0, 200);
239
- const rest = lines.slice(1, 6).join("\n").slice(0, 600);
240
- return `${prefix}${head}${rest ? "\n" + c.dim(rest) : ""}`;
241
- }
242
-
243
- export function assistant(text) {
244
- // Indent each line for visual separation from tool calls
245
- return text.split("\n").map((l) => ` ${l}`).join("\n");
246
- }
247
-
248
- export function errorLine(msg) {
249
- return `${c.red(c.bold("Error:"))} ${msg}`;
250
- }
251
-
252
- export function note(msg) {
253
- return c.dim(msg);
254
- }