aether-code 0.36.7 → 0.37.0

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/render.js CHANGED
@@ -26,6 +26,8 @@ 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.
29
31
  export function boxLines(lines, { color = c.gray, padX = 2, minInner = 0 } = {}) {
30
32
  const inner = Math.max(minInner, 0, ...lines.map((l) => stripAnsi(l).length));
31
33
  const w = inner + padX * 2;
@@ -41,6 +43,8 @@ export function box(lines, opts) {
41
43
  return boxLines(lines, opts).join("\n");
42
44
  }
43
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.
44
48
  export function sideBySide(a, b, gap = 3) {
45
49
  const wa = Math.max(0, ...a.map((l) => stripAnsi(l).length));
46
50
  const h = Math.max(a.length, b.length);
@@ -58,6 +62,7 @@ export function turn(n) {
58
62
  }
59
63
 
60
64
  export function toolHeader(name, args) {
65
+ // Format args compactly. If any value is huge, truncate it.
61
66
  const compact = JSON.stringify(args);
62
67
  const trimmed = compact.length > 120 ? compact.slice(0, 117) + "..." : compact;
63
68
  return `${c.cyan(c.bold(name))}${c.gray("(")}${c.gray(trimmed)}${c.gray(")")}`;
@@ -65,10 +70,12 @@ export function toolHeader(name, args) {
65
70
 
66
71
  const ellip = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + "…" : s || "");
67
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).
68
75
  export function toolLabel(name, args) {
69
76
  const a = args || {};
70
77
  const verb = (v) => c.cyan(c.bold(v));
71
- const arg = (s) => c.gray(ellip(s == null ? "" : String(s), 72));
78
+ const arg = (s) => c.gray(ellip(String(s), 72));
72
79
  switch (name) {
73
80
  case "read_file": return `${verb("read")} ${arg(a.path)}`;
74
81
  case "write_file": return `${verb("write")} ${arg(a.path)}`;
@@ -77,174 +84,68 @@ export function toolLabel(name, args) {
77
84
  case "glob_files": return `${verb("find")} ${arg(a.pattern)}`;
78
85
  case "search_files": return `${verb("search")} ${arg(`/${a.pattern}/ in ${a.path ?? "."}`)}`;
79
86
  case "run_shell": return `${verb("run")} ${arg(a.command)}`;
80
- case "web_search": return `${verb("search web")} ${arg(a.query ?? "")}`;
87
+ case "web_search": return `${verb("search web")} ${arg(JSON.stringify(a.query ?? ""))}`;
81
88
  case "web_fetch": return `${verb("fetch")} ${arg(a.url)}`;
82
- case "todo_write": return "";
83
- case "ask_user": return "";
89
+ case "todo_write": return ""; // its Plan box is the label
90
+ case "ask_user": return ""; // the interactive menu is its display
84
91
  default: return `${verb(name)} ${arg(JSON.stringify(a))}`;
85
92
  }
86
93
  }
87
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.
88
97
  export function setTerminalTitle(title) {
89
98
  if (process.stdout.isTTY) process.stdout.write(`\x1b]0;${title}\x07`);
90
99
  }
91
100
 
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
-
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.
132
105
  export function startSpinner(label) {
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", "."];
106
+ if (!process.stdout.isTTY) return { stop: () => {} };
107
+ const frames = ["◐", "◓", "◑", "◒"];
136
108
  const t0 = Date.now();
137
109
  let i = 0;
138
- let tokenCount = 0;
139
- let statusText = "";
140
- const isThinking = label === "thinking";
141
- const thinkWord = isThinking ? pickThinkingWord() : null;
142
- process.stdout.write("\x1b[?25l");
110
+ process.stdout.write("\x1b[?25l"); // hide cursor
143
111
  const render = () => {
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}`);
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}`);
156
115
  i++;
157
116
  };
158
117
  render();
159
118
  const timer = setInterval(render, 120);
160
- if (typeof timer.unref === "function") timer.unref();
119
+ if (typeof timer.unref === "function") timer.unref(); // don't keep the process alive
161
120
  return {
162
- update(data) {
163
- if (data.tokens != null) tokenCount = data.tokens;
164
- if (data.status != null) statusText = data.status;
165
- },
166
- stop() {
121
+ stop: () => {
167
122
  clearInterval(timer);
168
- process.stdout.write("\r\x1b[K\x1b[?25h");
123
+ process.stdout.write("\r\x1b[K\x1b[?25h"); // clear line + show cursor
169
124
  },
170
125
  };
171
126
  }
172
127
 
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(` Brewed for ${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
-
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.
213
131
  export function toolSummary(name, result) {
214
132
  const ok = result.ok;
133
+ // A tree branch links the result to the action above it (modern CLI style).
215
134
  const branch = ok ? c.green("└─") : c.red("└─");
216
135
  const out = result.output ?? "";
217
136
  const firstLine = out.split("\n").find((l) => l.trim()) ?? "";
218
137
 
219
138
  if (name === "run_shell") {
220
139
  let code = null;
221
- let stderr = "";
222
- try {
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)}`;
230
- }
231
- if (name === "todo_write") return "";
232
- if (name === "ask_user") return "";
233
- if (name === "write_file") {
234
- return ` ${branch} ${c.gray("wrote file")}`;
140
+ try { code = JSON.parse(out).exit_code; } catch { /* ignore */ }
141
+ return ` ${branch} ${c.gray(code === null ? (ok ? "done" : "failed") : `exit ${code}`)}`;
235
142
  }
236
- if (name === "edit_file") {
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.
237
147
  return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
238
148
  }
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`)}`;
247
- }
248
149
  let summary = "";
249
150
  try {
250
151
  const j = JSON.parse(out);
@@ -258,18 +159,28 @@ export function toolSummary(name, result) {
258
159
  return ` ${branch} ${c.gray(summary)}`;
259
160
  }
260
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.
261
168
  const MODEL_TOKEN_RE = /<\|[a-z_]*\|?>|<[a-z_]+\|>/gi;
262
169
 
263
170
  export function stripModelTokens(text) {
264
171
  return text.replace(MODEL_TOKEN_RE, "");
265
172
  }
266
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).
267
178
  export function makeTokenStripper() {
268
179
  let buf = "";
269
180
  return {
270
181
  push(text) {
271
182
  buf = (buf + text).replace(MODEL_TOKEN_RE, "");
272
- const partial = buf.match(/<[|a-z_/]*$/i);
183
+ const partial = buf.match(/<[|a-z_/]*$/i); // possible token start at the tail
273
184
  if (partial) {
274
185
  const emit = buf.slice(0, partial.index);
275
186
  buf = buf.slice(partial.index);
@@ -286,3 +197,25 @@ export function makeTokenStripper() {
286
197
  },
287
198
  };
288
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
+ }