aether-code 0.32.0 → 0.32.2

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
@@ -1,221 +1,221 @@
1
- // ANSI helpers — no chalk dependency.
2
-
3
- const isTty = process.stdout.isTTY;
4
- const noColor = !!process.env.NO_COLOR || !isTty;
5
-
6
- function wrap(open, close) {
7
- return (s) => (noColor ? String(s) : `\x1b[${open}m${s}\x1b[${close}m`);
8
- }
9
-
10
- export const c = {
11
- bold: wrap(1, 22),
12
- dim: wrap(2, 22),
13
- red: wrap(31, 39),
14
- green: wrap(32, 39),
15
- yellow: wrap(33, 39),
16
- blue: wrap(34, 39),
17
- magenta: wrap(35, 39),
18
- cyan: wrap(36, 39),
19
- gray: wrap(90, 39),
20
- };
21
-
22
- export function divider() {
23
- const w = process.stdout.columns || 60;
24
- return c.gray("─".repeat(Math.min(60, w)));
25
- }
26
-
27
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
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
- export function boxLines(lines, { color = c.gray, padX = 2, minInner = 0 } = {}) {
32
- const inner = Math.max(minInner, 0, ...lines.map((l) => stripAnsi(l).length));
33
- const w = inner + padX * 2;
34
- const top = color("╭" + "─".repeat(w) + "╮");
35
- const bot = color("╰" + "─".repeat(w) + "╯");
36
- const mid = lines.map(
37
- (l) => color("│") + " ".repeat(padX) + l + " ".repeat(inner - stripAnsi(l).length + padX) + color("│"),
38
- );
39
- return [top, ...mid, bot];
40
- }
41
-
42
- export function box(lines, opts) {
43
- return boxLines(lines, opts).join("\n");
44
- }
45
-
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
- export function sideBySide(a, b, gap = 3) {
49
- const wa = Math.max(0, ...a.map((l) => stripAnsi(l).length));
50
- const h = Math.max(a.length, b.length);
51
- const out = [];
52
- for (let i = 0; i < h; i++) {
53
- const la = a[i] ?? "";
54
- const lb = b[i] ?? "";
55
- out.push(la + " ".repeat(wa - stripAnsi(la).length + gap) + lb);
56
- }
57
- return out.join("\n");
58
- }
59
-
60
- export function turn(n) {
61
- return c.gray(`turn ${n}`);
62
- }
63
-
64
- export function toolHeader(name, args) {
65
- // Format args compactly. If any value is huge, truncate it.
66
- const compact = JSON.stringify(args);
67
- const trimmed = compact.length > 120 ? compact.slice(0, 117) + "..." : compact;
68
- return `${c.cyan(c.bold(name))}${c.gray("(")}${c.gray(trimmed)}${c.gray(")")}`;
69
- }
70
-
71
- const ellip = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + "…" : s || "");
72
-
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
- export function toolLabel(name, args) {
76
- const a = args || {};
77
- const verb = (v) => c.cyan(c.bold(v));
78
- const arg = (s) => c.gray(ellip(String(s), 72));
79
- 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
- default: return `${verb(name)} ${arg(JSON.stringify(a))}`;
92
- }
93
- }
94
-
95
- // Set the terminal window/tab title (so cmd.exe shows "Aether", not the node
96
- // path). OSC 0 — supported by cmd.exe, Windows Terminal, and POSIX terminals.
97
- export function setTerminalTitle(title) {
98
- if (process.stdout.isTTY) process.stdout.write(`\x1b]0;${title}\x07`);
99
- }
100
-
101
- // Live "working…" spinner. Rewrites a single line in place (\r) with a rotating
102
- // half-circle (Geometric Shapes — same block as ●, so cmd.exe-safe) + elapsed
103
- // seconds, so the user sees the agent is alive while the model thinks or a slow
104
- // tool runs. stop() clears the line and restores the cursor. No-op in non-TTY.
105
- export function startSpinner(label) {
106
- if (!process.stdout.isTTY) return { stop: () => {} };
107
- const frames = ["◐", "◓", "◑", "◒"];
108
- const t0 = Date.now();
109
- let i = 0;
110
- process.stdout.write("\x1b[?25l"); // hide cursor
111
- const render = () => {
112
- const s = Math.floor((Date.now() - t0) / 1000);
113
- const tail = s > 0 ? c.gray(` ${s}s`) : "";
114
- process.stdout.write(`\r\x1b[K${c.cyan(frames[i % frames.length])} ${c.gray(label + "…")}${tail}`);
115
- i++;
116
- };
117
- render();
118
- const timer = setInterval(render, 120);
119
- if (typeof timer.unref === "function") timer.unref(); // don't keep the process alive
120
- return {
121
- stop: () => {
122
- clearInterval(timer);
123
- process.stdout.write("\r\x1b[K\x1b[?25h"); // clear line + show cursor
124
- },
125
- };
126
- }
127
-
128
- // Terse one-line result summary instead of dumping raw JSON / file contents.
129
- // Tools whose handlers already render rich output (diffs, the shell stream, the
130
- // plan) just get a check — the detail was already printed.
131
- export function toolSummary(name, result) {
132
- const ok = result.ok;
133
- // A tree branch links the result to the action above it (modern CLI style).
134
- const branch = ok ? c.green("└─") : c.red("└─");
135
- const out = result.output ?? "";
136
- const firstLine = out.split("\n").find((l) => l.trim()) ?? "";
137
-
138
- if (name === "run_shell") {
139
- let code = null;
140
- try { code = JSON.parse(out).exit_code; } catch { /* ignore */ }
141
- return ` ${branch} ${c.gray(code === null ? (ok ? "done" : "failed") : `exit ${code}`)}`;
142
- }
143
- if (name === "todo_write") return ""; // the Plan is its own feedback
144
- if (name === "ask_user") return ""; // the menu already showed the answer
145
- if (name === "write_file" || name === "edit_file") {
146
- // Handler already printed the diff; echo its short status line.
147
- return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
148
- }
149
- let summary = "";
150
- try {
151
- const j = JSON.parse(out);
152
- if (Array.isArray(j)) summary = `${j.length} result${j.length === 1 ? "" : "s"}`;
153
- else if (Array.isArray(j.files)) summary = `${j.files.length} file${j.files.length === 1 ? "" : "s"}`;
154
- else if (Array.isArray(j.matches)) summary = `${j.matches.length} match${j.matches.length === 1 ? "" : "es"}`;
155
- } catch { /* not JSON */ }
156
- if (!summary) {
157
- summary = name === "read_file" ? `${out.split("\n").length} lines` : ellip(firstLine, 100);
158
- }
159
- return ` ${branch} ${c.gray(summary)}`;
160
- }
161
-
162
- // Strip model "harmony"/channel control tokens (<|channel|>, <|message|>,
163
- // <|tool_response|>, <channel|>, …) that occasionally leak into the text
164
- // stream. Belt-and-suspenders alongside the server-side scrub.
165
- // Only strips tokens containing a PIPE — the harmony/channel control tokens
166
- // (<|channel|>, <|tool_response|>, <channel|>) always have one. Real code like
167
- // <div>, Vec<T>, a < b has no pipe and is left untouched.
168
- const MODEL_TOKEN_RE = /<\|[a-z_]*\|?>|<[a-z_]+\|>/gi;
169
-
170
- export function stripModelTokens(text) {
171
- return text.replace(MODEL_TOKEN_RE, "");
172
- }
173
-
174
- // Streaming-safe stripper: the leaked tokens (<|channel|>, <|tool_response|>, …)
175
- // can be split across stream chunks ("<chann" then "el|>"), which a per-delta
176
- // regex misses. This buffers any trailing "<…" that might be the start of a
177
- // token and only emits it once it's confirmed not-a-token (or on flush).
178
- export function makeTokenStripper() {
179
- let buf = "";
180
- return {
181
- push(text) {
182
- buf = (buf + text).replace(MODEL_TOKEN_RE, "");
183
- const partial = buf.match(/<[|a-z_/]*$/i); // possible token start at the tail
184
- if (partial) {
185
- const emit = buf.slice(0, partial.index);
186
- buf = buf.slice(partial.index);
187
- return emit;
188
- }
189
- const emit = buf;
190
- buf = "";
191
- return emit;
192
- },
193
- flush() {
194
- const out = buf.replace(MODEL_TOKEN_RE, "");
195
- buf = "";
196
- return out;
197
- },
198
- };
199
- }
200
-
201
- export function toolResult(text, ok = true) {
202
- const prefix = ok ? c.green(" ● ") : c.red(" × ");
203
- // First line bold-ish, then dim continuation
204
- const lines = text.split("\n");
205
- const head = lines[0].slice(0, 200);
206
- const rest = lines.slice(1, 6).join("\n").slice(0, 600);
207
- return `${prefix}${head}${rest ? "\n" + c.dim(rest) : ""}`;
208
- }
209
-
210
- export function assistant(text) {
211
- // Indent each line for visual separation from tool calls
212
- return text.split("\n").map((l) => ` ${l}`).join("\n");
213
- }
214
-
215
- export function errorLine(msg) {
216
- return `${c.red(c.bold("Error:"))} ${msg}`;
217
- }
218
-
219
- export function note(msg) {
220
- return c.dim(msg);
221
- }
1
+ // ANSI helpers — no chalk dependency.
2
+
3
+ const isTty = process.stdout.isTTY;
4
+ const noColor = !!process.env.NO_COLOR || !isTty;
5
+
6
+ function wrap(open, close) {
7
+ return (s) => (noColor ? String(s) : `\x1b[${open}m${s}\x1b[${close}m`);
8
+ }
9
+
10
+ export const c = {
11
+ bold: wrap(1, 22),
12
+ dim: wrap(2, 22),
13
+ red: wrap(31, 39),
14
+ green: wrap(32, 39),
15
+ yellow: wrap(33, 39),
16
+ blue: wrap(34, 39),
17
+ magenta: wrap(35, 39),
18
+ cyan: wrap(36, 39),
19
+ gray: wrap(90, 39),
20
+ };
21
+
22
+ export function divider() {
23
+ const w = process.stdout.columns || 60;
24
+ return c.gray("─".repeat(Math.min(60, w)));
25
+ }
26
+
27
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
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
+ export function boxLines(lines, { color = c.gray, padX = 2, minInner = 0 } = {}) {
32
+ const inner = Math.max(minInner, 0, ...lines.map((l) => stripAnsi(l).length));
33
+ const w = inner + padX * 2;
34
+ const top = color("╭" + "─".repeat(w) + "╮");
35
+ const bot = color("╰" + "─".repeat(w) + "╯");
36
+ const mid = lines.map(
37
+ (l) => color("│") + " ".repeat(padX) + l + " ".repeat(inner - stripAnsi(l).length + padX) + color("│"),
38
+ );
39
+ return [top, ...mid, bot];
40
+ }
41
+
42
+ export function box(lines, opts) {
43
+ return boxLines(lines, opts).join("\n");
44
+ }
45
+
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
+ export function sideBySide(a, b, gap = 3) {
49
+ const wa = Math.max(0, ...a.map((l) => stripAnsi(l).length));
50
+ const h = Math.max(a.length, b.length);
51
+ const out = [];
52
+ for (let i = 0; i < h; i++) {
53
+ const la = a[i] ?? "";
54
+ const lb = b[i] ?? "";
55
+ out.push(la + " ".repeat(wa - stripAnsi(la).length + gap) + lb);
56
+ }
57
+ return out.join("\n");
58
+ }
59
+
60
+ export function turn(n) {
61
+ return c.gray(`turn ${n}`);
62
+ }
63
+
64
+ export function toolHeader(name, args) {
65
+ // Format args compactly. If any value is huge, truncate it.
66
+ const compact = JSON.stringify(args);
67
+ const trimmed = compact.length > 120 ? compact.slice(0, 117) + "..." : compact;
68
+ return `${c.cyan(c.bold(name))}${c.gray("(")}${c.gray(trimmed)}${c.gray(")")}`;
69
+ }
70
+
71
+ const ellip = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + "…" : s || "");
72
+
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
+ export function toolLabel(name, args) {
76
+ const a = args || {};
77
+ const verb = (v) => c.cyan(c.bold(v));
78
+ const arg = (s) => c.gray(ellip(s == null ? "" : String(s), 72));
79
+ 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
+ default: return `${verb(name)} ${arg(JSON.stringify(a))}`;
92
+ }
93
+ }
94
+
95
+ // Set the terminal window/tab title (so cmd.exe shows "Aether", not the node
96
+ // path). OSC 0 — supported by cmd.exe, Windows Terminal, and POSIX terminals.
97
+ export function setTerminalTitle(title) {
98
+ if (process.stdout.isTTY) process.stdout.write(`\x1b]0;${title}\x07`);
99
+ }
100
+
101
+ // Live "working…" spinner. Rewrites a single line in place (\r) with a rotating
102
+ // half-circle (Geometric Shapes — same block as ●, so cmd.exe-safe) + elapsed
103
+ // seconds, so the user sees the agent is alive while the model thinks or a slow
104
+ // tool runs. stop() clears the line and restores the cursor. No-op in non-TTY.
105
+ export function startSpinner(label) {
106
+ if (!process.stdout.isTTY) return { stop: () => {} };
107
+ const frames = ["◐", "◓", "◑", "◒"];
108
+ const t0 = Date.now();
109
+ let i = 0;
110
+ process.stdout.write("\x1b[?25l"); // hide cursor
111
+ const render = () => {
112
+ const s = Math.floor((Date.now() - t0) / 1000);
113
+ const tail = s > 0 ? c.gray(` ${s}s`) : "";
114
+ process.stdout.write(`\r\x1b[K${c.cyan(frames[i % frames.length])} ${c.gray(label + "…")}${tail}`);
115
+ i++;
116
+ };
117
+ render();
118
+ const timer = setInterval(render, 120);
119
+ if (typeof timer.unref === "function") timer.unref(); // don't keep the process alive
120
+ return {
121
+ stop: () => {
122
+ clearInterval(timer);
123
+ process.stdout.write("\r\x1b[K\x1b[?25h"); // clear line + show cursor
124
+ },
125
+ };
126
+ }
127
+
128
+ // Terse one-line result summary instead of dumping raw JSON / file contents.
129
+ // Tools whose handlers already render rich output (diffs, the shell stream, the
130
+ // plan) just get a check — the detail was already printed.
131
+ export function toolSummary(name, result) {
132
+ const ok = result.ok;
133
+ // A tree branch links the result to the action above it (modern CLI style).
134
+ const branch = ok ? c.green("└─") : c.red("└─");
135
+ const out = result.output ?? "";
136
+ const firstLine = out.split("\n").find((l) => l.trim()) ?? "";
137
+
138
+ if (name === "run_shell") {
139
+ let code = null;
140
+ try { code = JSON.parse(out).exit_code; } catch { /* ignore */ }
141
+ return ` ${branch} ${c.gray(code === null ? (ok ? "done" : "failed") : `exit ${code}`)}`;
142
+ }
143
+ if (name === "todo_write") return ""; // the Plan is its own feedback
144
+ if (name === "ask_user") return ""; // the menu already showed the answer
145
+ if (name === "write_file" || name === "edit_file") {
146
+ // Handler already printed the diff; echo its short status line.
147
+ return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
148
+ }
149
+ let summary = "";
150
+ try {
151
+ const j = JSON.parse(out);
152
+ if (Array.isArray(j)) summary = `${j.length} result${j.length === 1 ? "" : "s"}`;
153
+ else if (Array.isArray(j.files)) summary = `${j.files.length} file${j.files.length === 1 ? "" : "s"}`;
154
+ else if (Array.isArray(j.matches)) summary = `${j.matches.length} match${j.matches.length === 1 ? "" : "es"}`;
155
+ } catch { /* not JSON */ }
156
+ if (!summary) {
157
+ summary = name === "read_file" ? `${out.split("\n").length} lines` : ellip(firstLine, 100);
158
+ }
159
+ return ` ${branch} ${c.gray(summary)}`;
160
+ }
161
+
162
+ // Strip model "harmony"/channel control tokens (<|channel|>, <|message|>,
163
+ // <|tool_response|>, <channel|>, …) that occasionally leak into the text
164
+ // stream. Belt-and-suspenders alongside the server-side scrub.
165
+ // Only strips tokens containing a PIPE — the harmony/channel control tokens
166
+ // (<|channel|>, <|tool_response|>, <channel|>) always have one. Real code like
167
+ // <div>, Vec<T>, a < b has no pipe and is left untouched.
168
+ const MODEL_TOKEN_RE = /<\|[a-z_]*\|?>|<[a-z_]+\|>/gi;
169
+
170
+ export function stripModelTokens(text) {
171
+ return text.replace(MODEL_TOKEN_RE, "");
172
+ }
173
+
174
+ // Streaming-safe stripper: the leaked tokens (<|channel|>, <|tool_response|>, …)
175
+ // can be split across stream chunks ("<chann" then "el|>"), which a per-delta
176
+ // regex misses. This buffers any trailing "<…" that might be the start of a
177
+ // token and only emits it once it's confirmed not-a-token (or on flush).
178
+ export function makeTokenStripper() {
179
+ let buf = "";
180
+ return {
181
+ push(text) {
182
+ buf = (buf + text).replace(MODEL_TOKEN_RE, "");
183
+ const partial = buf.match(/<[|a-z_/]*$/i); // possible token start at the tail
184
+ if (partial) {
185
+ const emit = buf.slice(0, partial.index);
186
+ buf = buf.slice(partial.index);
187
+ return emit;
188
+ }
189
+ const emit = buf;
190
+ buf = "";
191
+ return emit;
192
+ },
193
+ flush() {
194
+ const out = buf.replace(MODEL_TOKEN_RE, "");
195
+ buf = "";
196
+ return out;
197
+ },
198
+ };
199
+ }
200
+
201
+ export function toolResult(text, ok = true) {
202
+ const prefix = ok ? c.green(" ● ") : c.red(" × ");
203
+ // First line bold-ish, then dim continuation
204
+ const lines = text.split("\n");
205
+ const head = lines[0].slice(0, 200);
206
+ const rest = lines.slice(1, 6).join("\n").slice(0, 600);
207
+ return `${prefix}${head}${rest ? "\n" + c.dim(rest) : ""}`;
208
+ }
209
+
210
+ export function assistant(text) {
211
+ // Indent each line for visual separation from tool calls
212
+ return text.split("\n").map((l) => ` ${l}`).join("\n");
213
+ }
214
+
215
+ export function errorLine(msg) {
216
+ return `${c.red(c.bold("Error:"))} ${msg}`;
217
+ }
218
+
219
+ export function note(msg) {
220
+ return c.dim(msg);
221
+ }