aether-code 0.36.6 → 0.36.8

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/repl.js CHANGED
@@ -1,17 +1,27 @@
1
1
  // Interactive REPL — Claude-CLI-style. Launched when `aether-code` is run
2
2
  // without a task argument.
3
+ //
4
+ // Behaviors:
5
+ // - Banner on startup (version, model, balance, cwd, mode)
6
+ // - Persistent message history across prompts within the session
7
+ // - Slash commands: /help, /exit, /clear, /balance, /cwd, /yes, /turns
8
+ // - Bottom status line is rendered after each turn
9
+ // - Ctrl+C: first press cancels in-progress turn, second press exits
3
10
 
4
11
  import path from "node:path";
5
12
  import { runAgent } from "./agent.js";
6
13
  import { fetchBalance, AetherError } from "./api.js";
7
14
  import { runSetup } from "./setup.js";
8
- import { c, errorLine, turnStats } from "./render.js";
9
- import { autoUpdate } from "./update-check.js";
15
+ import { c, errorLine, boxLines, sideBySide } from "./render.js";
16
+ import { checkForUpdate } from "./update-check.js";
10
17
  import { promptBox, EXIT_SIGNAL } from "./box-input.js";
11
18
 
12
- const VERSION = "0.36.6";
19
+ const VERSION = "0.32.0";
13
20
  const MODEL_NAME = "Aether Core";
14
21
 
22
+ // /model aliases → { id, name }. id=null = server default (gemma / Aether Core).
23
+ // Premium models are gated SERVER-SIDE: if you haven't purchased credits, the
24
+ // server transparently uses Core instead — selecting one here can't bypass that.
15
25
  const MODEL_ALIASES = {
16
26
  core: { id: null, name: "Aether Core" },
17
27
  gemma: { id: null, name: "Aether Core" },
@@ -33,39 +43,6 @@ ${c.gray("Anything else is sent to the agent as your next message.")}
33
43
  ${c.gray("Conversation history is kept across messages until you /clear.")}
34
44
  `;
35
45
 
36
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
37
-
38
- function getVisibleRows() {
39
- let rows = process.stdout.rows || 24;
40
- if (process.platform === "win32" && !process.env.WT_SESSION && rows > 60) {
41
- rows = 24;
42
- }
43
- return rows;
44
- }
45
-
46
- function pushPromptToBottom(contentLines) {
47
- const visibleRows = getVisibleRows();
48
- const promptHeight = 4;
49
- const gap = Math.max(0, visibleRows - contentLines - promptHeight);
50
- if (gap > 0) process.stdout.write("\n".repeat(gap));
51
- }
52
-
53
- function drawStaticPrompt(startRow, state) {
54
- const termCols = process.stdout.columns || 80;
55
- const ruleStr = `\x1b[36m ${"─".repeat(Math.max(0, termCols - 2))}\x1b[0m`;
56
- const modeStr = state.autoYes ? "auto-yes" : "review";
57
- const blkFn = (code) => `\x1b[${code}m \x1b[0m`;
58
- const statusBar = blkFn(46) + blkFn(44) +
59
- `\x1b[2m ${modeStr} mode \xB7 ? for shortcuts \xB7 @file for images\x1b[0m`;
60
- process.stdout.write(
61
- `\x1b[${startRow};1H` +
62
- ruleStr + "\n" +
63
- `\x1b[2m> \x1b[0m` + "\n" +
64
- ruleStr + "\n" +
65
- statusBar,
66
- );
67
- }
68
-
69
46
  export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafePaths: initialUnsafePaths, maxTurns: initialMaxTurns, model: initialModel = null, mcpManager = null }) {
70
47
  const initialAlias = Object.values(MODEL_ALIASES).find((a) => a.id === initialModel);
71
48
  const state = {
@@ -75,24 +52,29 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
75
52
  maxTurns: initialMaxTurns ?? 25,
76
53
  model: initialModel ?? null,
77
54
  modelName: initialAlias?.name ?? MODEL_NAME,
78
- messages: [],
55
+ messages: [], // accumulates across turns
79
56
  balance: null,
80
57
  sessionCredits: 0,
81
58
  sessionIn: 0,
82
59
  sessionOut: 0,
83
60
  };
84
61
 
85
- const updatePromise = autoUpdate().catch(() => null);
62
+ // Kick off the npm update check concurrently with the balance fetch so it
63
+ // adds no startup latency; the nudge (if any) prints just under the banner.
64
+ const updatePromise = checkForUpdate().catch(() => null);
86
65
 
66
+ // Free balance check up front. If no key configured, walk through first-time
67
+ // setup flow (open browser → paste key → verify → save).
87
68
  let needsSetup = false;
88
69
  try {
89
70
  const me = await fetchBalance();
90
71
  state.balance = me.balance;
91
- state.plan = me.plan || "FREE";
72
+ state.plan = me.plan;
92
73
  } catch (err) {
93
74
  if (err instanceof AetherError && (err.code === "NO_API_KEY" || err.status === 401)) {
94
75
  needsSetup = true;
95
76
  } else {
77
+ // Transient network or other — surface but don't block
96
78
  console.log(c.gray(`(could not fetch balance: ${err.message})`));
97
79
  }
98
80
  }
@@ -103,101 +85,52 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
103
85
  console.log(c.gray("Aborting — no valid API key."));
104
86
  process.exit(1);
105
87
  }
88
+ // Re-fetch balance now that the key is saved
106
89
  try {
107
90
  const me = await fetchBalance();
108
91
  state.balance = me.balance;
109
- state.plan = me.plan || "FREE";
110
- } catch { /* tolerate */ }
92
+ state.plan = me.plan;
93
+ } catch { /* tolerate — banner just won't show balance */ }
111
94
  }
112
95
 
113
- // Clear screen — clean start, no leftover shell text (matches Claude Code)
114
- process.stdout.write("\x1b[2J\x1b[H");
115
-
116
- const bannerLineCount = printBanner(state);
117
-
118
- const updateMsg = await updatePromise;
119
- let extraLines = 0;
120
- if (updateMsg) { console.log(updateMsg); extraLines++; }
121
-
122
- pushPromptToBottom(bannerLineCount + extraLines);
96
+ printBanner(state);
97
+ const updateNudge = await updatePromise;
98
+ if (updateNudge) console.log(updateNudge + "\n");
123
99
 
100
+ // Input: a bordered raw-mode box (box-input.js) — cmd.exe-safe and identical
101
+ // everywhere. (Ink's live redraw ghosts in the legacy Windows console, so we
102
+ // don't use it.) Each prompt is independent; the tool y/N prompts and the
103
+ // ask_user menu run BETWEEN prompts in raw mode too, so nothing overlaps.
124
104
  const inputHistory = [];
125
105
 
126
106
  while (true) {
127
- const raw = await promptBox({ history: inputHistory, mode: state.autoYes ? "auto-yes" : "review", suppressEcho: true });
107
+ const raw = await promptBox({ history: inputHistory });
128
108
  if (raw === EXIT_SIGNAL) { console.log(c.gray("bye.")); return; }
129
109
  const line = (raw ?? "").trim();
130
110
  if (!line) continue;
131
111
 
112
+ // promptBox already echoes "› <line>" on submit, so no echo needed here.
132
113
  inputHistory.push(line);
133
114
 
115
+ // Slash command?
134
116
  if (line.startsWith("/") || line === "?") {
135
117
  const handled = await handleSlash(line, state);
136
118
  if (handled === "exit") return;
119
+ printStatusLine(state);
137
120
  continue;
138
121
  }
139
122
 
140
- // Build banner + echo as one buffer, write atomically (no flash, no garbling)
141
- const bLines = buildBannerLines(state);
142
- const echoDisplay = line.length > 200 ? line.slice(0, 197) + "..." : line;
143
- const echoCols = process.stdout.columns || 80;
144
- const echoLen = echoDisplay.length + 2;
145
- const echoPad = Math.max(0, echoCols - echoLen);
146
- const echoBar = `\x1b[48;5;236m ${echoDisplay}${" ".repeat(echoPad)}\x1b[0m`;
147
-
148
- const vr = getVisibleRows();
149
- let scrollEnd = Math.max(4, vr - 4);
150
- let promptRow = scrollEnd + 1;
151
-
152
- // Clear screen, print banner + echo, draw static prompt at bottom,
153
- // then set scroll region so agent output stays above the prompt.
154
- process.stdout.write(
155
- "\x1b[r" +
156
- "\x1b[2J\x1b[H" +
157
- bLines.join("\n") + "\n" + echoBar + "\n",
158
- );
159
- drawStaticPrompt(promptRow, state);
160
-
161
- const agentRow = Math.min(bLines.length + 2, scrollEnd);
162
- process.stdout.write(`\x1b[1;${scrollEnd}r\x1b[${agentRow};1H`);
163
-
164
- const onResize = () => {
165
- const newVr = getVisibleRows();
166
- const newScrollEnd = Math.max(4, newVr - 4);
167
- const newPromptRow = newScrollEnd + 1;
168
- process.stdout.write(
169
- "\x1b7" +
170
- "\x1b[r" +
171
- `\x1b[${promptRow};1H\x1b[J`,
172
- );
173
- drawStaticPrompt(newPromptRow, state);
174
- process.stdout.write(
175
- `\x1b[1;${newScrollEnd}r` +
176
- "\x1b8",
177
- );
178
- scrollEnd = newScrollEnd;
179
- promptRow = newPromptRow;
180
- };
181
- process.stdout.on("resize", onResize);
182
-
183
- const t0 = Date.now();
184
- let result;
185
- try {
186
- result = await runAgent({
187
- initialPrompt: line,
188
- priorMessages: state.messages.length > 0 ? state.messages : undefined,
189
- cwd: state.cwd,
190
- autoYes: state.autoYes,
191
- unsafePaths: state.unsafePaths,
192
- model: state.model,
193
- maxTurns: state.maxTurns,
194
- mcpManager,
195
- });
196
- } finally {
197
- process.stdout.removeListener("resize", onResize);
198
- process.stdout.write("\x1b[r");
199
- }
200
- const elapsed = Date.now() - t0;
123
+ // Otherwise — send as a message to the agent
124
+ const result = await runAgent({
125
+ initialPrompt: line,
126
+ priorMessages: state.messages.length > 0 ? state.messages : undefined,
127
+ cwd: state.cwd,
128
+ autoYes: state.autoYes,
129
+ unsafePaths: state.unsafePaths,
130
+ model: state.model,
131
+ maxTurns: state.maxTurns,
132
+ mcpManager,
133
+ });
201
134
 
202
135
  state.sessionCredits += result.totalCredits ?? 0;
203
136
  state.sessionIn += result.totalIn ?? 0;
@@ -207,20 +140,10 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
207
140
 
208
141
  if (!result.ok && result.error) {
209
142
  console.log("\n" + errorLine(result.error.message || String(result.error)));
143
+ // Don't kill the session on a single error — surface it and continue.
210
144
  }
211
145
 
212
- const stats = turnStats({
213
- elapsed,
214
- tokensIn: 0,
215
- tokensOut: 0,
216
- tools: 0,
217
- credits: result.totalCredits ?? 0,
218
- });
219
- if (stats) console.log(stats);
220
- console.log("");
221
-
222
- // Position cursor at prompt area for the interactive prompt
223
- process.stdout.write(`\x1b[${promptRow};1H\x1b[J`);
146
+ printStatusLine(state);
224
147
  }
225
148
  }
226
149
 
@@ -252,10 +175,10 @@ async function handleSlash(line, state) {
252
175
  try {
253
176
  const me = await fetchBalance();
254
177
  state.balance = me.balance;
255
- state.plan = me.plan || "FREE";
178
+ state.plan = me.plan;
256
179
  console.log(
257
180
  c.cyan(`balance: ${me.balance.toLocaleString()} credits`) +
258
- c.gray(` \xB7 plan: ${me.plan || "FREE"} (${me.planCredits ?? 0} plan + ${me.topupCredits ?? 0} topup)`),
181
+ c.gray(` · plan: ${me.plan} (${me.planCredits} plan + ${me.topupCredits} topup)`),
259
182
  );
260
183
  } catch (err) {
261
184
  console.log(errorLine(err.message || String(err)));
@@ -268,7 +191,7 @@ async function handleSlash(line, state) {
268
191
  try {
269
192
  process.chdir(next);
270
193
  state.cwd = next;
271
- console.log(c.gray(`cwd -> ${state.cwd}`));
194
+ console.log(c.gray(`cwd → ${state.cwd}`));
272
195
  } catch (err) {
273
196
  console.log(errorLine(err.message));
274
197
  }
@@ -300,7 +223,7 @@ async function handleSlash(line, state) {
300
223
  state.modelName = MODEL_ALIASES[key].name;
301
224
  const premium = state.model !== null;
302
225
  console.log(
303
- c.gray(`model -> ${c.cyan(state.modelName)}`) +
226
+ c.gray(`model → ${c.cyan(state.modelName)}`) +
304
227
  (premium ? c.gray(" (premium — needs purchased credits, else falls back to Core)") : ""),
305
228
  );
306
229
  } else {
@@ -314,85 +237,55 @@ async function handleSlash(line, state) {
314
237
  return null;
315
238
  }
316
239
 
317
- /* ───────── banner (two-column like Claude Code) ───────── */
240
+ /* ───────── banner + status ───────── */
318
241
 
242
+ const visLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
319
243
  const shortenPath = (p, max) => (p.length <= max ? p : "..." + p.slice(p.length - max + 3));
320
244
 
321
- function padRight(str, width) {
322
- const visible = stripAnsi(str).length;
323
- const need = Math.max(0, width - visible);
324
- return str + " ".repeat(need);
325
- }
245
+ // Short, hand-curated highlights shown in the "What's new" box. Update with
246
+ // notable releases — keep it to ~3 lines so it balances the banner height.
247
+ const WHATS_NEW = [
248
+ "Files now save to your Desktop",
249
+ "Live thinking spinner + loop guard",
250
+ "Asks you when a task is unclear",
251
+ ];
326
252
 
327
- function centerIn(str, width) {
328
- const visible = stripAnsi(str).length;
329
- if (visible >= width) return str;
330
- const left = Math.floor((width - visible) / 2);
331
- return " ".repeat(left) + str;
332
- }
333
-
334
- function buildBannerLines(state) {
335
- const cols = process.stdout.columns || 80;
253
+ function printBanner(state) {
254
+ const mode = state.autoYes ? (state.unsafePaths ? "skip-permissions" : "auto-yes") : "review mode";
336
255
  const credits = state.balance != null ? `${state.balance.toLocaleString()} credits` : "";
337
- const plan = state.plan && state.plan !== "FREE" ? state.plan : "";
338
-
339
- const blk = (code) => `\x1b[${code}m \x1b[0m`;
340
- const C = blk(46);
341
- const B = blk(44);
342
-
343
- const modelParts = [state.modelName];
344
- if (credits) modelParts.push(credits);
345
- if (plan) modelParts.push(c.cyan(plan));
346
- const modelLine = modelParts.join(c.dim(" \xB7 "));
347
-
348
- const ruleW = cols - 2;
349
- const leftW = Math.min(Math.floor(cols * 0.5), 45);
350
- const twoCol = cols >= 70;
351
-
352
- const titleText = " Aether Code v" + VERSION + " ";
353
- const titleRuleRight = Math.max(0, ruleW - 3 - titleText.length);
354
- const topRule = c.cyan("──" + titleText + "─".repeat(titleRuleRight));
355
-
356
- const VL = c.cyan("│");
357
- const lines = [];
358
-
359
- lines.push("");
360
- lines.push(" " + topRule);
361
-
362
- const bottomRule = c.cyan(" " + "─".repeat(ruleW));
363
-
364
- if (twoCol) {
365
- const row = (left, right) => {
366
- lines.push(" " + padRight(left, leftW) + " " + VL + " " + right);
367
- };
256
+ const left = boxLines(
257
+ [
258
+ c.bold(c.magenta("aether")) + c.gray(" · ") + c.bold(state.modelName) + c.gray(" v" + VERSION),
259
+ c.gray("uncensored AI coding agent"),
260
+ c.gray((credits ? credits + " · " : "") + mode),
261
+ c.gray(shortenPath(state.cwd, 52)),
262
+ ],
263
+ { color: c.magenta, padX: 2 },
264
+ );
265
+ const right = boxLines(
266
+ [c.bold(c.cyan("What's new")), ...WHATS_NEW.map((s) => c.gray("• " + s))],
267
+ { color: c.cyan, padX: 2 },
268
+ );
368
269
 
369
- row(centerIn(c.bold("Welcome back!"), leftW), c.bold("Tips for getting started"));
370
- row("", c.dim("Ask Aether to build, debug, or refactor code"));
371
- row(centerIn(`${C}${B} ${C}`, leftW), "");
372
- row(centerIn(`${B}${C} ${B}`, leftW), c.yellow("What's new"));
373
- row(centerIn(`${C}${B} ${C}`, leftW), c.dim("Creative thinking words while processing"));
374
- row("", c.dim("Prompt pins to bottom, content scrolls up"));
375
- row("", "");
376
- row(centerIn(modelLine, leftW), "");
377
- row(centerIn(c.dim(shortenPath(state.cwd, 50)), leftW), "");
270
+ console.log("");
271
+ const cols = process.stdout.columns || 80;
272
+ // Only place the two boxes side by side when there's room; otherwise the
273
+ // banner alone (narrow terminals would wrap and tear the second box).
274
+ if (cols >= visLen(left[0]) + visLen(right[0]) + 4) {
275
+ console.log(sideBySide(left, right, 3));
378
276
  } else {
379
- lines.push("");
380
- lines.push(centerIn(c.bold("Welcome back!"), cols - 4));
381
- lines.push("");
382
- lines.push(centerIn(`${C}${B} ${C}`, cols - 4));
383
- lines.push(centerIn(`${B}${C} ${B}`, cols - 4));
384
- lines.push(centerIn(`${C}${B} ${C}`, cols - 4));
385
- lines.push("");
386
- lines.push(centerIn(modelLine, cols - 4));
387
- lines.push(centerIn(c.dim(shortenPath(state.cwd, 50)), cols - 4));
277
+ console.log(left.join("\n"));
388
278
  }
389
-
390
- lines.push(bottomRule);
391
- return lines;
279
+ console.log("");
392
280
  }
393
281
 
394
- function printBanner(state) {
395
- const lines = buildBannerLines(state);
396
- for (const l of lines) console.log(l);
397
- return lines.length;
282
+ function printStatusLine(state) {
283
+ const parts = [];
284
+ parts.push(c.gray(`session: ${state.sessionCredits} cr · ${state.sessionIn}→${state.sessionOut} tokens`));
285
+ if (state.balance != null) parts.push(c.gray(`balance: ${state.balance.toLocaleString()}`));
286
+ if (state.messages.length > 0) {
287
+ parts.push(c.gray(`history: ${state.messages.length} msg${state.messages.length === 1 ? "" : "s"}`));
288
+ }
289
+ parts.push(c.cyan(state.autoYes ? "auto-yes" : "review"));
290
+ console.log(c.dim(parts.join(" · ")));
398
291
  }