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/repl.js CHANGED
@@ -1,34 +1,17 @@
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
10
-
11
- import fs from "node:fs";
3
+
12
4
  import path from "node:path";
13
5
  import { runAgent } from "./agent.js";
14
6
  import { fetchBalance, AetherError } from "./api.js";
15
7
  import { runSetup } from "./setup.js";
16
- import { c, errorLine, boxLines, sideBySide } from "./render.js";
17
- import { checkForUpdate } from "./update-check.js";
8
+ import { c, errorLine, turnStats } from "./render.js";
9
+ import { autoUpdate } from "./update-check.js";
18
10
  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();
11
+
12
+ const VERSION = "0.43.1";
27
13
  const MODEL_NAME = "Aether Core";
28
14
 
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
15
  const MODEL_ALIASES = {
33
16
  core: { id: null, name: "Aether Core" },
34
17
  gemma: { id: null, name: "Aether Core" },
@@ -37,23 +20,51 @@ const MODEL_ALIASES = {
37
20
  };
38
21
 
39
22
  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.")}
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.")}
55
33
  `;
56
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
+
57
68
  export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafePaths: initialUnsafePaths, maxTurns: initialMaxTurns, model: initialModel = null, mcpManager = null }) {
58
69
  const initialAlias = Object.values(MODEL_ALIASES).find((a) => a.id === initialModel);
59
70
  const state = {
@@ -68,17 +79,15 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
68
79
  sessionCredits: 0,
69
80
  sessionIn: 0,
70
81
  sessionOut: 0,
71
- sessionId: generateSessionId(initialCwd),
72
- turnCount: 0,
73
82
  };
74
83
 
75
- const updatePromise = checkForUpdate().catch(() => null);
84
+ const updatePromise = autoUpdate().catch(() => null);
76
85
 
77
86
  let needsSetup = false;
78
87
  try {
79
88
  const me = await fetchBalance();
80
89
  state.balance = me.balance;
81
- state.plan = me.plan;
90
+ state.plan = me.plan || "FREE";
82
91
  } catch (err) {
83
92
  if (err instanceof AetherError && (err.code === "NO_API_KEY" || err.status === 401)) {
84
93
  needsSetup = true;
@@ -96,54 +105,33 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
96
105
  try {
97
106
  const me = await fetchBalance();
98
107
  state.balance = me.balance;
99
- state.plan = me.plan;
108
+ state.plan = me.plan || "FREE";
100
109
  } catch { /* tolerate */ }
101
110
  }
102
111
 
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 */ }
112
+ // Clear screen — clean start, no leftover shell text (matches Claude Code)
113
+ process.stdout.write("\x1b[2J\x1b[H");
127
114
 
128
- // Project quick-scan for banner
129
- let projectLine = "";
130
- try {
131
- projectLine = scanProjectQuick(state.cwd);
132
- } catch { /* non-fatal */ }
115
+ const bannerLineCount = printBanner(state);
133
116
 
134
- printBanner(state, projectLine, resumed);
135
- const updateNudge = await updatePromise;
136
- if (updateNudge) console.log(updateNudge + "\n");
117
+ const updateMsg = await updatePromise;
118
+ let extraLines = 0;
119
+ if (updateMsg) { console.log(updateMsg); extraLines++; }
120
+
121
+ pushPromptToBottom(bannerLineCount + extraLines);
137
122
 
138
123
  const inputHistory = [];
139
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
+
140
132
  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
- }
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; }
147
135
  const line = (raw ?? "").trim();
148
136
  if (!line) continue;
149
137
 
@@ -151,25 +139,71 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
151
139
 
152
140
  if (line.startsWith("/") || line === "?") {
153
141
  const handled = await handleSlash(line, state);
154
- if (handled === "exit") {
155
- autoSave(state);
156
- return;
157
- }
158
- printStatusLine(state);
142
+ if (handled === "exit") return;
159
143
  continue;
160
144
  }
161
145
 
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
- });
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;
173
207
 
174
208
  state.sessionCredits += result.totalCredits ?? 0;
175
209
  state.sessionIn += result.totalIn ?? 0;
@@ -181,27 +215,19 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
181
215
  console.log("\n" + errorLine(result.error.message || String(result.error)));
182
216
  }
183
217
 
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),
218
+ const stats = turnStats({
219
+ elapsed,
220
+ tokensIn: 0,
221
+ tokensOut: 0,
222
+ tools: 0,
223
+ credits: result.totalCredits ?? 0,
203
224
  });
204
- } catch { /* non-fatal — don't kill the session over a save failure */ }
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
+ }
205
231
  }
206
232
 
207
233
  /* ───────── slash commands ───────── */
@@ -232,10 +258,10 @@ async function handleSlash(line, state) {
232
258
  try {
233
259
  const me = await fetchBalance();
234
260
  state.balance = me.balance;
235
- state.plan = me.plan;
261
+ state.plan = me.plan || "FREE";
236
262
  console.log(
237
263
  c.cyan(`balance: ${me.balance.toLocaleString()} credits`) +
238
- c.gray(` · plan: ${me.plan} (${me.planCredits} plan + ${me.topupCredits} topup)`),
264
+ c.gray(` \xB7 plan: ${me.plan || "FREE"} (${me.planCredits ?? 0} plan + ${me.topupCredits ?? 0} topup)`),
239
265
  );
240
266
  } catch (err) {
241
267
  console.log(errorLine(err.message || String(err)));
@@ -248,7 +274,7 @@ async function handleSlash(line, state) {
248
274
  try {
249
275
  process.chdir(next);
250
276
  state.cwd = next;
251
- console.log(c.gray(`cwd → ${state.cwd}`));
277
+ console.log(c.gray(`cwd -> ${state.cwd}`));
252
278
  } catch (err) {
253
279
  console.log(errorLine(err.message));
254
280
  }
@@ -280,7 +306,7 @@ async function handleSlash(line, state) {
280
306
  state.modelName = MODEL_ALIASES[key].name;
281
307
  const premium = state.model !== null;
282
308
  console.log(
283
- c.gray(`model → ${c.cyan(state.modelName)}`) +
309
+ c.gray(`model -> ${c.cyan(state.modelName)}`) +
284
310
  (premium ? c.gray(" (premium — needs purchased credits, else falls back to Core)") : ""),
285
311
  );
286
312
  } else {
@@ -288,157 +314,95 @@ async function handleSlash(line, state) {
288
314
  }
289
315
  break;
290
316
  }
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
317
  default:
386
318
  console.log(errorLine(`Unknown command: /${cmd}. Type ${c.cyan("/help")} for shortcuts.`));
387
319
  }
388
320
  return null;
389
321
  }
390
322
 
391
- /* ───────── banner + status ───────── */
323
+ /* ───────── banner (two-column like Claude Code) ───────── */
392
324
 
393
- const visLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
394
325
  const shortenPath = (p, max) => (p.length <= max ? p : "..." + p.slice(p.length - max + 3));
395
326
 
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
- ];
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
+ }
403
332
 
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
- );
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
+ }
424
339
 
425
- console.log("");
340
+ function buildBannerLines(state) {
426
341
  const cols = process.stdout.columns || 80;
427
- if (cols >= visLen(left[0]) + visLen(right[0]) + 4) {
428
- console.log(sideBySide(left, right, 3));
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("", "");
429
386
  } else {
430
- console.log(left.join("\n"));
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("");
431
398
  }
432
- console.log("");
399
+
400
+ lines.push(bottomRule);
401
+ return lines;
433
402
  }
434
403
 
435
- function printStatusLine(state) {
436
- const parts = [];
437
- parts.push(c.gray(`session: ${state.sessionCredits} cr · ${state.sessionIn}→${state.sessionOut} tokens`));
438
- if (state.balance != null) parts.push(c.gray(`balance: ${state.balance.toLocaleString()}`));
439
- if (state.messages.length > 0) {
440
- parts.push(c.gray(`history: ${state.messages.length} msg${state.messages.length === 1 ? "" : "s"}`));
441
- }
442
- parts.push(c.cyan(state.autoYes ? "auto-yes" : "review"));
443
- console.log(c.dim(parts.join(" · ")));
404
+ function printBanner(state) {
405
+ const lines = buildBannerLines(state);
406
+ for (const l of lines) console.log(l);
407
+ return lines.length;
444
408
  }