aether-code 0.43.1 → 0.43.3

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,408 +1,441 @@
1
- // Interactive REPL — Claude-CLI-style. Launched when `aether-code` is run
2
- // without a task argument.
3
-
4
- import path from "node:path";
5
- import { runAgent } from "./agent.js";
6
- import { fetchBalance, AetherError } from "./api.js";
7
- import { runSetup } from "./setup.js";
8
- import { c, errorLine, turnStats } from "./render.js";
9
- import { autoUpdate } from "./update-check.js";
10
- import { promptBox, EXIT_SIGNAL } from "./box-input.js";
11
-
12
- const VERSION = "0.43.1";
13
- const MODEL_NAME = "Aether Core";
14
-
15
- const MODEL_ALIASES = {
16
- core: { id: null, name: "Aether Core" },
17
- gemma: { id: null, name: "Aether Core" },
18
- ultra: { id: "claude-opus-4-6", name: "Aether Ultra" },
19
- opus: { id: "claude-opus-4-6", name: "Aether Ultra" },
20
- };
21
-
22
- const SHORTCUTS = `
23
- ${c.cyan("/help")} Show this help
24
- ${c.cyan("/exit")} Exit (or Ctrl+C twice)
25
- ${c.cyan("/clear")} Clear conversation history
26
- ${c.cyan("/balance")} Show credit balance
27
- ${c.cyan("/cwd")} ${c.gray("[path]")} Show or change working directory
28
- ${c.cyan("/yes")} Toggle auto-accept mode (skip confirmations)
29
- ${c.cyan("/turns")} ${c.gray("<n>")} Set max agent turns per prompt (default 25)
30
- ${c.cyan("/model")} ${c.gray("[name]")} Switch model ${c.gray("(core | ultra)")}
31
-
32
- ${c.dim("Anything else is sent as your next message. History persists until /clear.")}
33
- `;
34
-
35
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
36
-
37
- function getVisibleRows() {
38
- let rows = process.stdout.rows || 24;
39
- if (process.platform === "win32" && !process.env.WT_SESSION && rows > 60) {
40
- rows = 24;
41
- }
42
- return rows;
43
- }
44
-
45
- function pushPromptToBottom(contentLines) {
46
- const visibleRows = getVisibleRows();
47
- const promptHeight = 4;
48
- const gap = Math.max(0, visibleRows - contentLines - promptHeight);
49
- if (gap > 0) process.stdout.write("\n".repeat(gap));
50
- }
51
-
52
- function drawStaticPrompt(startRow, state) {
53
- const termCols = process.stdout.columns || 80;
54
- const ruleStr = `\x1b[36m${"─".repeat(Math.max(0, termCols))}\x1b[0m`;
55
- const modeStr = state.autoYes ? "auto-accept" : "confirm writes";
56
- const blkFn = (code) => `\x1b[${code}m \x1b[0m`;
57
- const statusBar = blkFn("48;2;209;107;52") + blkFn("48;2;0;178;169") +
58
- `\x1b[2m ${modeStr} \xB7 /help for shortcuts \xB7 @file to include\x1b[0m`;
59
- process.stdout.write(
60
- `\x1b[${startRow};1H` +
61
- ruleStr + "\n" +
62
- `\x1b[2m> \x1b[0m` + "\n" +
63
- ruleStr + "\n" +
64
- statusBar,
65
- );
66
- }
67
-
68
- export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafePaths: initialUnsafePaths, maxTurns: initialMaxTurns, model: initialModel = null, mcpManager = null }) {
69
- const initialAlias = Object.values(MODEL_ALIASES).find((a) => a.id === initialModel);
70
- const state = {
71
- cwd: initialCwd,
72
- autoYes: !!initialAutoYes,
73
- unsafePaths: !!initialUnsafePaths,
74
- maxTurns: initialMaxTurns ?? 25,
75
- model: initialModel ?? null,
76
- modelName: initialAlias?.name ?? MODEL_NAME,
77
- messages: [],
78
- balance: null,
79
- sessionCredits: 0,
80
- sessionIn: 0,
81
- sessionOut: 0,
82
- };
83
-
84
- const updatePromise = autoUpdate().catch(() => null);
85
-
86
- let needsSetup = false;
87
- try {
88
- const me = await fetchBalance();
89
- state.balance = me.balance;
90
- state.plan = me.plan || "FREE";
91
- } catch (err) {
92
- if (err instanceof AetherError && (err.code === "NO_API_KEY" || err.status === 401)) {
93
- needsSetup = true;
94
- } else {
95
- console.log(c.gray(`(could not fetch balance: ${err.message})`));
96
- }
97
- }
98
-
99
- if (needsSetup) {
100
- const ok = await runSetup();
101
- if (!ok) {
102
- console.log(c.gray("Aborting — no valid API key."));
103
- process.exit(1);
104
- }
105
- try {
106
- const me = await fetchBalance();
107
- state.balance = me.balance;
108
- state.plan = me.plan || "FREE";
109
- } catch { /* tolerate */ }
110
- }
111
-
112
- // Clear screen — clean start, no leftover shell text (matches Claude Code)
113
- process.stdout.write("\x1b[2J\x1b[H");
114
-
115
- const bannerLineCount = printBanner(state);
116
-
117
- const updateMsg = await updatePromise;
118
- let extraLines = 0;
119
- if (updateMsg) { console.log(updateMsg); extraLines++; }
120
-
121
- pushPromptToBottom(bannerLineCount + extraLines);
122
-
123
- const inputHistory = [];
124
-
125
- const handlePromptResize = () => {
126
- const vr = getVisibleRows();
127
- process.stdout.write("\x1b[2J\x1b[H");
128
- printBanner(state);
129
- process.stdout.write(`\x1b[${Math.max(1, vr - 3)};1H`);
130
- };
131
-
132
- while (true) {
133
- const raw = await promptBox({ history: inputHistory, mode: state.autoYes ? "auto-yes" : "review", suppressEcho: true, onResize: handlePromptResize });
134
- if (raw === EXIT_SIGNAL) { console.log(c.gray("bye.")); return; }
135
- const line = (raw ?? "").trim();
136
- if (!line) continue;
137
-
138
- inputHistory.push(line);
139
-
140
- if (line.startsWith("/") || line === "?") {
141
- const handled = await handleSlash(line, state);
142
- if (handled === "exit") return;
143
- continue;
144
- }
145
-
146
- // Build banner + echo as one buffer, write atomically (no flash, no garbling)
147
- const bLines = buildBannerLines(state);
148
- const echoDisplay = line.length > 200 ? line.slice(0, 197) + "..." : line;
149
- const echoCols = process.stdout.columns || 80;
150
- const echoLen = echoDisplay.length + 2;
151
- const echoPad = Math.max(0, echoCols - echoLen);
152
- const echoBar = `\x1b[48;5;236m ${echoDisplay}${" ".repeat(echoPad)}\x1b[0m`;
153
-
154
- const vr = getVisibleRows();
155
- let scrollEnd = Math.max(4, vr - 4);
156
- let promptRow = scrollEnd + 1;
157
-
158
- // Clear screen, print banner + echo, draw static prompt at bottom,
159
- // then set scroll region so agent output stays above the prompt.
160
- process.stdout.write(
161
- "\x1b[r" +
162
- "\x1b[2J\x1b[H" +
163
- bLines.join("\n") + "\n" + echoBar + "\n",
164
- );
165
- drawStaticPrompt(promptRow, state);
166
-
167
- const agentRow = Math.min(bLines.length + 2, scrollEnd);
168
- process.stdout.write(`\x1b[1;${scrollEnd}r\x1b[${agentRow};1H`);
169
-
170
- const onResize = () => {
171
- const newVr = getVisibleRows();
172
- const newScrollEnd = Math.max(4, newVr - 4);
173
- const newPromptRow = newScrollEnd + 1;
174
- process.stdout.write(
175
- "\x1b7" +
176
- "\x1b[r" +
177
- `\x1b[${promptRow};1H\x1b[J`,
178
- );
179
- drawStaticPrompt(newPromptRow, state);
180
- process.stdout.write(
181
- `\x1b[1;${newScrollEnd}r` +
182
- "\x1b8",
183
- );
184
- scrollEnd = newScrollEnd;
185
- promptRow = newPromptRow;
186
- };
187
- process.stdout.on("resize", onResize);
188
-
189
- const t0 = Date.now();
190
- let result;
191
- try {
192
- result = await runAgent({
193
- initialPrompt: line,
194
- priorMessages: state.messages.length > 0 ? state.messages : undefined,
195
- cwd: state.cwd,
196
- autoYes: state.autoYes,
197
- unsafePaths: state.unsafePaths,
198
- model: state.model,
199
- maxTurns: state.maxTurns,
200
- mcpManager,
201
- });
202
- } finally {
203
- process.stdout.removeListener("resize", onResize);
204
- process.stdout.write("\x1b[r");
205
- }
206
- const elapsed = Date.now() - t0;
207
-
208
- state.sessionCredits += result.totalCredits ?? 0;
209
- state.sessionIn += result.totalIn ?? 0;
210
- state.sessionOut += result.totalOut ?? 0;
211
- if (typeof result.balance === "number") state.balance = result.balance;
212
- if (result.messages) state.messages = result.messages;
213
-
214
- if (!result.ok && result.error) {
215
- console.log("\n" + errorLine(result.error.message || String(result.error)));
216
- }
217
-
218
- const stats = turnStats({
219
- elapsed,
220
- tokensIn: 0,
221
- tokensOut: 0,
222
- tools: 0,
223
- credits: result.totalCredits ?? 0,
224
- });
225
- if (stats) console.log(stats);
226
- console.log("");
227
-
228
- // Position cursor at prompt area for the interactive prompt
229
- process.stdout.write(`\x1b[${promptRow};1H\x1b[J`);
230
- }
231
- }
232
-
233
- /* ───────── slash commands ───────── */
234
-
235
- async function handleSlash(line, state) {
236
- const [cmd, ...rest] = line.replace(/^\//, "").split(/\s+/);
237
- const arg = rest.join(" ").trim();
238
-
239
- switch ((cmd || "").toLowerCase()) {
240
- case "":
241
- case "help":
242
- case "?":
243
- console.log(SHORTCUTS);
244
- break;
245
- case "exit":
246
- case "quit":
247
- case "q":
248
- console.log(c.gray("bye."));
249
- return "exit";
250
- case "clear":
251
- state.messages = [];
252
- state.sessionCredits = 0;
253
- state.sessionIn = 0;
254
- state.sessionOut = 0;
255
- console.log(c.gray("Conversation cleared."));
256
- break;
257
- case "balance": {
258
- try {
259
- const me = await fetchBalance();
260
- state.balance = me.balance;
261
- state.plan = me.plan || "FREE";
262
- console.log(
263
- c.cyan(`balance: ${me.balance.toLocaleString()} credits`) +
264
- c.gray(` \xB7 plan: ${me.plan || "FREE"} (${me.planCredits ?? 0} plan + ${me.topupCredits ?? 0} topup)`),
265
- );
266
- } catch (err) {
267
- console.log(errorLine(err.message || String(err)));
268
- }
269
- break;
270
- }
271
- case "cwd":
272
- if (arg) {
273
- const next = path.resolve(arg);
274
- try {
275
- process.chdir(next);
276
- state.cwd = next;
277
- console.log(c.gray(`cwd -> ${state.cwd}`));
278
- } catch (err) {
279
- console.log(errorLine(err.message));
280
- }
281
- } else {
282
- console.log(c.gray(`cwd: ${state.cwd}`));
283
- }
284
- break;
285
- case "yes":
286
- state.autoYes = !state.autoYes;
287
- console.log(c.gray(`auto-yes: ${state.autoYes ? "on (writes/shells will skip y/N)" : "off"}`));
288
- break;
289
- case "turns": {
290
- const n = parseInt(arg, 10);
291
- if (!Number.isFinite(n) || n < 1 || n > 200) {
292
- console.log(errorLine(`/turns expects 1..200, got "${arg}"`));
293
- } else {
294
- state.maxTurns = n;
295
- console.log(c.gray(`max turns: ${state.maxTurns}`));
296
- }
297
- break;
298
- }
299
- case "model": {
300
- const key = (arg || "").trim().toLowerCase();
301
- if (!key) {
302
- console.log(c.gray(`model: ${state.modelName}`));
303
- console.log(c.gray("switch with /model core | ultra (ultra=Opus — premium, need credits)"));
304
- } else if (MODEL_ALIASES[key]) {
305
- state.model = MODEL_ALIASES[key].id;
306
- state.modelName = MODEL_ALIASES[key].name;
307
- const premium = state.model !== null;
308
- console.log(
309
- c.gray(`model -> ${c.cyan(state.modelName)}`) +
310
- (premium ? c.gray(" (premium — needs purchased credits, else falls back to Core)") : ""),
311
- );
312
- } else {
313
- console.log(errorLine(`Unknown model "${key}". Try: core, ultra`));
314
- }
315
- break;
316
- }
317
- default:
318
- console.log(errorLine(`Unknown command: /${cmd}. Type ${c.cyan("/help")} for shortcuts.`));
319
- }
320
- return null;
321
- }
322
-
323
- /* ───────── banner (two-column like Claude Code) ───────── */
324
-
325
- const shortenPath = (p, max) => (p.length <= max ? p : "..." + p.slice(p.length - max + 3));
326
-
327
- function padRight(str, width) {
328
- const visible = stripAnsi(str).length;
329
- const need = Math.max(0, width - visible);
330
- return str + " ".repeat(need);
331
- }
332
-
333
- function centerIn(str, width) {
334
- const visible = stripAnsi(str).length;
335
- if (visible >= width) return str;
336
- const left = Math.floor((width - visible) / 2);
337
- return " ".repeat(left) + str;
338
- }
339
-
340
- function buildBannerLines(state) {
341
- const cols = process.stdout.columns || 80;
342
- const credits = state.balance != null ? `${state.balance.toLocaleString()} credits` : "";
343
- const plan = state.plan && state.plan !== "FREE" ? state.plan : "";
344
-
345
- const blk = (code) => `\x1b[${code}m \x1b[0m`;
346
- const O = blk("48;2;209;107;52");
347
- const W = blk("48;2;255;255;255");
348
- const T = blk("48;2;0;178;169");
349
-
350
- const modelParts = [state.modelName];
351
- if (credits) modelParts.push(credits);
352
- if (plan) modelParts.push(c.cyan(plan));
353
- const modelLine = modelParts.join(c.dim(" \xB7 "));
354
-
355
- const ruleW = cols - 2;
356
- const leftW = Math.min(Math.floor(cols * 0.5), 45);
357
- const twoCol = cols >= 70;
358
-
359
- const titleText = " Aether Code v" + VERSION + " ";
360
- const titleRuleRight = Math.max(0, ruleW - 3 - titleText.length);
361
- const topRule = c.cyan("╭─" + titleText + "─".repeat(titleRuleRight) + "╮");
362
-
363
- const VL = c.cyan("│");
364
- const lines = [];
365
-
366
- lines.push("");
367
- lines.push(" " + topRule);
368
-
369
- const bottomRule = c.cyan(" ╰" + "─".repeat(ruleW) + "╯");
370
-
371
- if (twoCol) {
372
- const row = (left, right) => {
373
- lines.push(" " + VL + padRight(left, leftW) + " " + VL + " " + padRight(right, Math.max(0, ruleW - leftW - 4)) + VL);
374
- };
375
-
376
- row("", "");
377
- row(centerIn(`${O}${T}${O}`, leftW), c.bold("Tips"));
378
- row(centerIn(`${T}${O}${T}`, leftW), c.dim("Ask Aether to build, debug, or refactor code"));
379
- row(centerIn(`${O}${T}${O}`, leftW), c.dim("Use /help for shortcuts and /balance for credits"));
380
- row("", c.dim("Type @ to include file paths or image paths"));
381
- row(centerIn(c.bold("/model") + c.dim(":") + c.cyan(state.modelName.toLowerCase().replace("aether ", "")), leftW), "");
382
- row(centerIn(modelLine, leftW), "");
383
- row("", "");
384
- row(centerIn(c.dim("cwd: " + shortenPath(state.cwd, 40)), leftW), "");
385
- row("", "");
386
- } else {
387
- const srow = (content) => {
388
- lines.push(" " + VL + " " + padRight(content, Math.max(0, ruleW - 2)) + VL);
389
- };
390
- srow("");
391
- srow(centerIn(`${O}${T}${O}`, ruleW - 2));
392
- srow(centerIn(`${T}${O}${T}`, ruleW - 2));
393
- srow(centerIn(`${O}${T}${O}`, ruleW - 2));
394
- srow("");
395
- srow(centerIn(modelLine, ruleW - 2));
396
- srow(centerIn(c.dim(shortenPath(state.cwd, 50)), ruleW - 2));
397
- srow("");
398
- }
399
-
400
- lines.push(bottomRule);
401
- return lines;
402
- }
403
-
404
- function printBanner(state) {
405
- const lines = buildBannerLines(state);
406
- for (const l of lines) console.log(l);
407
- return lines.length;
408
- }
1
+ // Interactive REPL — Claude-CLI-style. Launched when `aether-code` is run
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
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { runAgent } from "./agent.js";
14
+ import { fetchBalance, AetherError } from "./api.js";
15
+ import { runSetup } from "./setup.js";
16
+ import { c, errorLine, boxLines, sideBySide } from "./render.js";
17
+ import { checkForUpdate } from "./update-check.js";
18
+ import { promptBox, EXIT_SIGNAL } from "./box-input.js";
19
+ import { getVersion } from "./version.js";
20
+ import {
21
+ generateSessionId, saveSession, loadSession, listSessions,
22
+ findRecentSession, deleteSession, extractSummary, formatAge,
23
+ } from "./sessions.js";
24
+ import { scanProjectQuick, generateProjectIndex } from "./project-context.js";
25
+
26
+ const VERSION = getVersion();
27
+ const MODEL_NAME = "Aether Core";
28
+
29
+ // /model aliases → { id, name }. id=null = server default (gemma / Aether Core).
30
+ // Premium models are gated SERVER-SIDE: if you haven't purchased credits, the
31
+ // server transparently uses Core instead — selecting one here can't bypass that.
32
+ const MODEL_ALIASES = {
33
+ core: { id: null, name: "Aether Core" },
34
+ gemma: { id: null, name: "Aether Core" },
35
+ ultra: { id: "claude-opus-4-6", name: "Aether Ultra" },
36
+ opus: { id: "claude-opus-4-6", name: "Aether Ultra" },
37
+ };
38
+
39
+ const SHORTCUTS = `
40
+ ${c.cyan("/help")} Show this help
41
+ ${c.cyan("/exit")} Exit (or Ctrl+C twice)
42
+ ${c.cyan("/clear")} Clear conversation history (start fresh)
43
+ ${c.cyan("/balance")} Refresh and show credit balance
44
+ ${c.cyan("/cwd")} ${c.gray("[path]")} Show or change working directory
45
+ ${c.cyan("/yes")} Toggle auto-approve mode (skip y/N prompts)
46
+ ${c.cyan("/turns")} ${c.gray("<n>")} Set max turns per prompt (default 25)
47
+ ${c.cyan("/model")} ${c.gray("[core|ultra]")} Show or switch model (ultra=Opus — premium)
48
+ ${c.cyan("/init")} Deep-scan project → generate .aether/context.md
49
+ ${c.cyan("/sessions")} List saved sessions for this directory
50
+ ${c.cyan("/resume")} ${c.gray("[id]")} Resume a saved session (default: most recent)
51
+ ${c.cyan("/new")} Start a fresh session (save current first)
52
+
53
+ ${c.gray("Anything else is sent to the agent as your next message.")}
54
+ ${c.gray("Sessions auto-save — close the terminal and come back later.")}
55
+ `;
56
+
57
+ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafePaths: initialUnsafePaths, maxTurns: initialMaxTurns, model: initialModel = null, mcpManager = null }) {
58
+ const initialAlias = Object.values(MODEL_ALIASES).find((a) => a.id === initialModel);
59
+ const state = {
60
+ cwd: initialCwd,
61
+ autoYes: !!initialAutoYes,
62
+ unsafePaths: !!initialUnsafePaths,
63
+ maxTurns: initialMaxTurns ?? 25,
64
+ model: initialModel ?? null,
65
+ modelName: initialAlias?.name ?? MODEL_NAME,
66
+ messages: [],
67
+ balance: null,
68
+ sessionCredits: 0,
69
+ sessionIn: 0,
70
+ sessionOut: 0,
71
+ sessionId: generateSessionId(initialCwd),
72
+ turnCount: 0,
73
+ };
74
+
75
+ const updatePromise = checkForUpdate().catch(() => null);
76
+
77
+ let needsSetup = false;
78
+ try {
79
+ const me = await fetchBalance();
80
+ state.balance = me.balance;
81
+ state.plan = me.plan;
82
+ } catch (err) {
83
+ if (err instanceof AetherError && (err.code === "NO_API_KEY" || err.status === 401)) {
84
+ needsSetup = true;
85
+ } else {
86
+ console.log(c.gray(`(could not fetch balance: ${err.message})`));
87
+ }
88
+ }
89
+
90
+ if (needsSetup) {
91
+ const ok = await runSetup();
92
+ if (!ok) {
93
+ console.log(c.gray("Aborting — no valid API key."));
94
+ process.exit(1);
95
+ }
96
+ try {
97
+ const me = await fetchBalance();
98
+ state.balance = me.balance;
99
+ state.plan = me.plan;
100
+ } catch { /* tolerate */ }
101
+ }
102
+
103
+ // Check for a resumable session before printing the banner
104
+ let resumed = false;
105
+ try {
106
+ const recent = findRecentSession(state.cwd);
107
+ if (recent && recent.messageCount >= 2) {
108
+ const full = loadSession(recent.id);
109
+ if (full && full.messages?.length >= 2) {
110
+ console.log(
111
+ c.gray(`\n Found session from ${formatAge(recent.updatedAt)} — `) +
112
+ c.cyan(extractSummary(full.messages) || "(no summary)"),
113
+ );
114
+ console.log(c.gray(" Resuming automatically. Use /new to start fresh.\n"));
115
+ state.sessionId = full.id;
116
+ state.messages = full.messages;
117
+ state.sessionCredits = full.credits ?? 0;
118
+ state.sessionIn = full.tokensIn ?? 0;
119
+ state.sessionOut = full.tokensOut ?? 0;
120
+ state.turnCount = full.turnCount ?? 0;
121
+ if (full.model !== undefined) state.model = full.model;
122
+ if (full.modelName) state.modelName = full.modelName;
123
+ resumed = true;
124
+ }
125
+ }
126
+ } catch { /* non-fatal */ }
127
+
128
+ // Project quick-scan for banner
129
+ let projectLine = "";
130
+ try {
131
+ projectLine = scanProjectQuick(state.cwd);
132
+ } catch { /* non-fatal */ }
133
+
134
+ printBanner(state, projectLine, resumed);
135
+ const updateNudge = await updatePromise;
136
+ if (updateNudge) console.log(updateNudge + "\n");
137
+
138
+ const inputHistory = [];
139
+
140
+ while (true) {
141
+ const raw = await promptBox({ history: inputHistory });
142
+ if (raw === EXIT_SIGNAL) {
143
+ autoSave(state);
144
+ console.log(c.gray("bye."));
145
+ return;
146
+ }
147
+ const line = (raw ?? "").trim();
148
+ if (!line) continue;
149
+
150
+ inputHistory.push(line);
151
+
152
+ if (line.startsWith("/") || line === "?") {
153
+ const handled = await handleSlash(line, state);
154
+ if (handled === "exit") {
155
+ autoSave(state);
156
+ return;
157
+ }
158
+ printStatusLine(state);
159
+ continue;
160
+ }
161
+
162
+ state.turnCount++;
163
+ const result = await runAgent({
164
+ initialPrompt: line,
165
+ priorMessages: state.messages.length > 0 ? state.messages : undefined,
166
+ cwd: state.cwd,
167
+ autoYes: state.autoYes,
168
+ unsafePaths: state.unsafePaths,
169
+ model: state.model,
170
+ maxTurns: state.maxTurns,
171
+ mcpManager,
172
+ });
173
+
174
+ state.sessionCredits += result.totalCredits ?? 0;
175
+ state.sessionIn += result.totalIn ?? 0;
176
+ state.sessionOut += result.totalOut ?? 0;
177
+ if (typeof result.balance === "number") state.balance = result.balance;
178
+ if (result.messages) state.messages = result.messages;
179
+
180
+ if (!result.ok && result.error) {
181
+ console.log("\n" + errorLine(result.error.message || String(result.error)));
182
+ }
183
+
184
+ autoSave(state);
185
+ printStatusLine(state);
186
+ }
187
+ }
188
+
189
+ function autoSave(state) {
190
+ if (state.messages.length < 2) return;
191
+ try {
192
+ saveSession({
193
+ id: state.sessionId,
194
+ cwd: state.cwd,
195
+ model: state.model,
196
+ modelName: state.modelName,
197
+ messages: state.messages,
198
+ credits: state.sessionCredits,
199
+ tokensIn: state.sessionIn,
200
+ tokensOut: state.sessionOut,
201
+ turnCount: state.turnCount,
202
+ summary: extractSummary(state.messages),
203
+ });
204
+ } catch { /* non-fatal — don't kill the session over a save failure */ }
205
+ }
206
+
207
+ /* ───────── slash commands ───────── */
208
+
209
+ async function handleSlash(line, state) {
210
+ const [cmd, ...rest] = line.replace(/^\//, "").split(/\s+/);
211
+ const arg = rest.join(" ").trim();
212
+
213
+ switch ((cmd || "").toLowerCase()) {
214
+ case "":
215
+ case "help":
216
+ case "?":
217
+ console.log(SHORTCUTS);
218
+ break;
219
+ case "exit":
220
+ case "quit":
221
+ case "q":
222
+ console.log(c.gray("bye."));
223
+ return "exit";
224
+ case "clear":
225
+ state.messages = [];
226
+ state.sessionCredits = 0;
227
+ state.sessionIn = 0;
228
+ state.sessionOut = 0;
229
+ console.log(c.gray("Conversation cleared."));
230
+ break;
231
+ case "balance": {
232
+ try {
233
+ const me = await fetchBalance();
234
+ state.balance = me.balance;
235
+ state.plan = me.plan;
236
+ console.log(
237
+ c.cyan(`balance: ${me.balance.toLocaleString()} credits`) +
238
+ c.gray(` · plan: ${me.plan} (${me.planCredits} plan + ${me.topupCredits} topup)`),
239
+ );
240
+ } catch (err) {
241
+ console.log(errorLine(err.message || String(err)));
242
+ }
243
+ break;
244
+ }
245
+ case "cwd":
246
+ if (arg) {
247
+ const next = path.resolve(arg);
248
+ try {
249
+ process.chdir(next);
250
+ state.cwd = next;
251
+ console.log(c.gray(`cwd → ${state.cwd}`));
252
+ } catch (err) {
253
+ console.log(errorLine(err.message));
254
+ }
255
+ } else {
256
+ console.log(c.gray(`cwd: ${state.cwd}`));
257
+ }
258
+ break;
259
+ case "yes":
260
+ state.autoYes = !state.autoYes;
261
+ console.log(c.gray(`auto-yes: ${state.autoYes ? "on (writes/shells will skip y/N)" : "off"}`));
262
+ break;
263
+ case "turns": {
264
+ const n = parseInt(arg, 10);
265
+ if (!Number.isFinite(n) || n < 1 || n > 200) {
266
+ console.log(errorLine(`/turns expects 1..200, got "${arg}"`));
267
+ } else {
268
+ state.maxTurns = n;
269
+ console.log(c.gray(`max turns: ${state.maxTurns}`));
270
+ }
271
+ break;
272
+ }
273
+ case "model": {
274
+ const key = (arg || "").trim().toLowerCase();
275
+ if (!key) {
276
+ console.log(c.gray(`model: ${state.modelName}`));
277
+ console.log(c.gray("switch with /model core | ultra (ultra=Opus — premium, need credits)"));
278
+ } else if (MODEL_ALIASES[key]) {
279
+ state.model = MODEL_ALIASES[key].id;
280
+ state.modelName = MODEL_ALIASES[key].name;
281
+ const premium = state.model !== null;
282
+ console.log(
283
+ c.gray(`model → ${c.cyan(state.modelName)}`) +
284
+ (premium ? c.gray(" (premium — needs purchased credits, else falls back to Core)") : ""),
285
+ );
286
+ } else {
287
+ console.log(errorLine(`Unknown model "${key}". Try: core, ultra`));
288
+ }
289
+ break;
290
+ }
291
+ case "init": {
292
+ console.log(c.gray("Scanning project..."));
293
+ try {
294
+ const content = generateProjectIndex(state.cwd);
295
+ const dir = path.join(state.cwd, ".aether");
296
+ fs.mkdirSync(dir, { recursive: true });
297
+ const filePath = path.join(dir, "context.md");
298
+ fs.writeFileSync(filePath, content, "utf8");
299
+ const lineCount = content.split("\n").length;
300
+ console.log(c.green("●") + " " + c.bold(`.aether/context.md`) + c.gray(` generated (${lineCount} lines)`));
301
+ console.log(c.gray(" The agent now reads this on every turn. Edit it freely to add project-specific notes."));
302
+ // Add to .gitignore if not already there
303
+ const gitignorePath = path.join(state.cwd, ".gitignore");
304
+ try {
305
+ const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : "";
306
+ if (!existing.includes(".aether/")) {
307
+ fs.appendFileSync(gitignorePath, `\n# Aether agent context\n.aether/\n`, "utf8");
308
+ console.log(c.gray(" Added .aether/ to .gitignore"));
309
+ }
310
+ } catch { /* non-fatal */ }
311
+ } catch (err) {
312
+ console.log(errorLine(`/init failed: ${err.message}`));
313
+ }
314
+ break;
315
+ }
316
+ case "sessions": {
317
+ const sessions = listSessions(state.cwd);
318
+ if (sessions.length === 0) {
319
+ console.log(c.gray("No saved sessions for this directory."));
320
+ } else {
321
+ console.log(c.bold("Saved sessions") + c.gray(` (${state.cwd})`));
322
+ for (const s of sessions.slice(0, 10)) {
323
+ const active = s.id === state.sessionId ? c.cyan(" ← current") : "";
324
+ const summary = s.summary || "(no summary)";
325
+ const truncated = summary.length > 60 ? summary.slice(0, 57) + "..." : summary;
326
+ console.log(
327
+ ` ${c.gray(formatAge(s.updatedAt).padEnd(8))} ` +
328
+ `${c.gray(s.id.slice(0, 16))} ` +
329
+ `${truncated}` +
330
+ `${c.gray(` · ${s.turnCount} turns`)}` +
331
+ active,
332
+ );
333
+ }
334
+ if (sessions.length > 10) {
335
+ console.log(c.gray(` ... and ${sessions.length - 10} more`));
336
+ }
337
+ console.log(c.gray("\nUse /resume <id-prefix> to load one."));
338
+ }
339
+ break;
340
+ }
341
+ case "resume": {
342
+ const target = (arg || "").trim();
343
+ let session = null;
344
+ if (target) {
345
+ const sessions = listSessions(state.cwd);
346
+ const match = sessions.find((s) => s.id.startsWith(target));
347
+ if (match) session = loadSession(match.id);
348
+ if (!session) {
349
+ console.log(errorLine(`No session matching "${target}" found.`));
350
+ break;
351
+ }
352
+ } else {
353
+ const recent = findRecentSession(state.cwd);
354
+ if (recent) session = loadSession(recent.id);
355
+ if (!session) {
356
+ console.log(errorLine("No recent session to resume. Use /sessions to list."));
357
+ break;
358
+ }
359
+ }
360
+ autoSave(state);
361
+ state.sessionId = session.id;
362
+ state.messages = session.messages ?? [];
363
+ state.sessionCredits = session.credits ?? 0;
364
+ state.sessionIn = session.tokensIn ?? 0;
365
+ state.sessionOut = session.tokensOut ?? 0;
366
+ state.turnCount = session.turnCount ?? 0;
367
+ if (session.model !== undefined) state.model = session.model;
368
+ if (session.modelName) state.modelName = session.modelName;
369
+ const summary = extractSummary(session.messages) || "(no summary)";
370
+ console.log(c.gray(`Resumed: `) + c.cyan(summary));
371
+ console.log(c.gray(` ${state.messages.length} messages · ${state.turnCount} turns · ${formatAge(session.updatedAt)}`));
372
+ break;
373
+ }
374
+ case "new": {
375
+ autoSave(state);
376
+ state.sessionId = generateSessionId(state.cwd);
377
+ state.messages = [];
378
+ state.sessionCredits = 0;
379
+ state.sessionIn = 0;
380
+ state.sessionOut = 0;
381
+ state.turnCount = 0;
382
+ console.log(c.gray("New session started. Previous session saved."));
383
+ break;
384
+ }
385
+ default:
386
+ console.log(errorLine(`Unknown command: /${cmd}. Type ${c.cyan("/help")} for shortcuts.`));
387
+ }
388
+ return null;
389
+ }
390
+
391
+ /* ───────── banner + status ───────── */
392
+
393
+ const visLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
394
+ const shortenPath = (p, max) => (p.length <= max ? p : "..." + p.slice(p.length - max + 3));
395
+
396
+ // Short, hand-curated highlights shown in the "What's new" box. Update with
397
+ // notable releases — keep it to ~3 lines so it balances the banner height.
398
+ const WHATS_NEW = [
399
+ "Session persistence — close & resume",
400
+ "Project auto-understanding on startup",
401
+ "Live thinking spinner + loop guard",
402
+ ];
403
+
404
+ function printBanner(state, projectLine = "", resumed = false) {
405
+ const mode = state.autoYes ? (state.unsafePaths ? "skip-permissions" : "auto-yes") : "review mode";
406
+ const credits = state.balance != null ? `${state.balance.toLocaleString()} credits` : "";
407
+ const leftLines = [
408
+ c.bold(c.magenta("aether")) + c.gray(" · ") + c.bold(state.modelName) + c.gray(" v" + VERSION),
409
+ c.gray("uncensored AI coding agent"),
410
+ c.gray((credits ? credits + " · " : "") + mode),
411
+ c.gray(shortenPath(state.cwd, 52)),
412
+ ];
413
+ if (projectLine) {
414
+ leftLines.push(c.gray("project: " + (projectLine.length > 48 ? projectLine.slice(0, 45) + "..." : projectLine)));
415
+ }
416
+ if (resumed) {
417
+ leftLines.push(c.cyan("↻ resumed session") + c.gray(` · ${state.turnCount} turns · ${state.messages.length} msgs`));
418
+ }
419
+ const left = boxLines(leftLines, { color: c.magenta, padX: 2 });
420
+ const right = boxLines(
421
+ [c.bold(c.cyan("What's new")), ...WHATS_NEW.map((s) => c.gray("• " + s))],
422
+ { color: c.cyan, padX: 2 },
423
+ );
424
+
425
+ console.log("");
426
+ const cols = process.stdout.columns || 80;
427
+ if (cols >= visLen(left[0]) + visLen(right[0]) + 4) {
428
+ console.log(sideBySide(left, right, 3));
429
+ } else {
430
+ console.log(left.join("\n"));
431
+ }
432
+ console.log("");
433
+ }
434
+
435
+ function printStatusLine(state) {
436
+ const parts = [];
437
+ if (state.balance != null) parts.push(`${state.balance.toLocaleString()} credits`);
438
+ parts.push(state.autoYes ? "skip-permissions" : "review mode");
439
+ if (state.messages.length > 0) parts.push(`${state.messages.length} msgs`);
440
+ console.log(c.dim(parts.join(" · ")));
441
+ }