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/repl.js CHANGED
@@ -1,17 +1,33 @@
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
-
12
- const VERSION = "0.36.7";
18
+ import { getVersion } from "./version.js";
19
+ import {
20
+ generateSessionId, saveSession, loadSession, listSessions,
21
+ findRecentSession, deleteSession, extractSummary, formatAge,
22
+ } from "./sessions.js";
23
+ import { scanProjectQuick } from "./project-context.js";
24
+
25
+ const VERSION = getVersion();
13
26
  const MODEL_NAME = "Aether Core";
14
27
 
28
+ // /model aliases → { id, name }. id=null = server default (gemma / Aether Core).
29
+ // Premium models are gated SERVER-SIDE: if you haven't purchased credits, the
30
+ // server transparently uses Core instead — selecting one here can't bypass that.
15
31
  const MODEL_ALIASES = {
16
32
  core: { id: null, name: "Aether Core" },
17
33
  gemma: { id: null, name: "Aether Core" },
@@ -28,44 +44,14 @@ const SHORTCUTS = `
28
44
  ${c.cyan("/yes")} Toggle auto-approve mode (skip y/N prompts)
29
45
  ${c.cyan("/turns")} ${c.gray("<n>")} Set max turns per prompt (default 25)
30
46
  ${c.cyan("/model")} ${c.gray("[core|ultra]")} Show or switch model (ultra=Opus — premium)
47
+ ${c.cyan("/sessions")} List saved sessions for this directory
48
+ ${c.cyan("/resume")} ${c.gray("[id]")} Resume a saved session (default: most recent)
49
+ ${c.cyan("/new")} Start a fresh session (save current first)
31
50
 
32
51
  ${c.gray("Anything else is sent to the agent as your next message.")}
33
- ${c.gray("Conversation history is kept across messages until you /clear.")}
52
+ ${c.gray("Sessions auto-save — close the terminal and come back later.")}
34
53
  `;
35
54
 
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
55
  export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafePaths: initialUnsafePaths, maxTurns: initialMaxTurns, model: initialModel = null, mcpManager = null }) {
70
56
  const initialAlias = Object.values(MODEL_ALIASES).find((a) => a.id === initialModel);
71
57
  const state = {
@@ -80,15 +66,17 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
80
66
  sessionCredits: 0,
81
67
  sessionIn: 0,
82
68
  sessionOut: 0,
69
+ sessionId: generateSessionId(initialCwd),
70
+ turnCount: 0,
83
71
  };
84
72
 
85
- const updatePromise = autoUpdate().catch(() => null);
73
+ const updatePromise = checkForUpdate().catch(() => null);
86
74
 
87
75
  let needsSetup = false;
88
76
  try {
89
77
  const me = await fetchBalance();
90
78
  state.balance = me.balance;
91
- state.plan = me.plan || "FREE";
79
+ state.plan = me.plan;
92
80
  } catch (err) {
93
81
  if (err instanceof AetherError && (err.code === "NO_API_KEY" || err.status === 401)) {
94
82
  needsSetup = true;
@@ -106,33 +94,54 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
106
94
  try {
107
95
  const me = await fetchBalance();
108
96
  state.balance = me.balance;
109
- state.plan = me.plan || "FREE";
97
+ state.plan = me.plan;
110
98
  } catch { /* tolerate */ }
111
99
  }
112
100
 
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);
101
+ // Check for a resumable session before printing the banner
102
+ let resumed = false;
103
+ try {
104
+ const recent = findRecentSession(state.cwd);
105
+ if (recent && recent.messageCount >= 2) {
106
+ const full = loadSession(recent.id);
107
+ if (full && full.messages?.length >= 2) {
108
+ console.log(
109
+ c.gray(`\n Found session from ${formatAge(recent.updatedAt)} — `) +
110
+ c.cyan(extractSummary(full.messages) || "(no summary)"),
111
+ );
112
+ console.log(c.gray(" Resuming automatically. Use /new to start fresh.\n"));
113
+ state.sessionId = full.id;
114
+ state.messages = full.messages;
115
+ state.sessionCredits = full.credits ?? 0;
116
+ state.sessionIn = full.tokensIn ?? 0;
117
+ state.sessionOut = full.tokensOut ?? 0;
118
+ state.turnCount = full.turnCount ?? 0;
119
+ if (full.model !== undefined) state.model = full.model;
120
+ if (full.modelName) state.modelName = full.modelName;
121
+ resumed = true;
122
+ }
123
+ }
124
+ } catch { /* non-fatal */ }
117
125
 
118
- const updateMsg = await updatePromise;
119
- let extraLines = 0;
120
- if (updateMsg) { console.log(updateMsg); extraLines++; }
126
+ // Project quick-scan for banner
127
+ let projectLine = "";
128
+ try {
129
+ projectLine = scanProjectQuick(state.cwd);
130
+ } catch { /* non-fatal */ }
121
131
 
122
- pushPromptToBottom(bannerLineCount + extraLines);
132
+ printBanner(state, projectLine, resumed);
133
+ const updateNudge = await updatePromise;
134
+ if (updateNudge) console.log(updateNudge + "\n");
123
135
 
124
136
  const inputHistory = [];
125
137
 
126
- const handlePromptResize = () => {
127
- const vr = getVisibleRows();
128
- process.stdout.write("\x1b[2J\x1b[H");
129
- printBanner(state);
130
- process.stdout.write(`\x1b[${Math.max(1, vr - 3)};1H`);
131
- };
132
-
133
138
  while (true) {
134
- const raw = await promptBox({ history: inputHistory, mode: state.autoYes ? "auto-yes" : "review", suppressEcho: true, onResize: handlePromptResize });
135
- if (raw === EXIT_SIGNAL) { console.log(c.gray("bye.")); return; }
139
+ const raw = await promptBox({ history: inputHistory });
140
+ if (raw === EXIT_SIGNAL) {
141
+ autoSave(state);
142
+ console.log(c.gray("bye."));
143
+ return;
144
+ }
136
145
  const line = (raw ?? "").trim();
137
146
  if (!line) continue;
138
147
 
@@ -140,71 +149,25 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
140
149
 
141
150
  if (line.startsWith("/") || line === "?") {
142
151
  const handled = await handleSlash(line, state);
143
- if (handled === "exit") return;
152
+ if (handled === "exit") {
153
+ autoSave(state);
154
+ return;
155
+ }
156
+ printStatusLine(state);
144
157
  continue;
145
158
  }
146
159
 
147
- // Build banner + echo as one buffer, write atomically (no flash, no garbling)
148
- const bLines = buildBannerLines(state);
149
- const echoDisplay = line.length > 200 ? line.slice(0, 197) + "..." : line;
150
- const echoCols = process.stdout.columns || 80;
151
- const echoLen = echoDisplay.length + 2;
152
- const echoPad = Math.max(0, echoCols - echoLen);
153
- const echoBar = `\x1b[48;5;236m ${echoDisplay}${" ".repeat(echoPad)}\x1b[0m`;
154
-
155
- const vr = getVisibleRows();
156
- let scrollEnd = Math.max(4, vr - 4);
157
- let promptRow = scrollEnd + 1;
158
-
159
- // Clear screen, print banner + echo, draw static prompt at bottom,
160
- // then set scroll region so agent output stays above the prompt.
161
- process.stdout.write(
162
- "\x1b[r" +
163
- "\x1b[2J\x1b[H" +
164
- bLines.join("\n") + "\n" + echoBar + "\n",
165
- );
166
- drawStaticPrompt(promptRow, state);
167
-
168
- const agentRow = Math.min(bLines.length + 2, scrollEnd);
169
- process.stdout.write(`\x1b[1;${scrollEnd}r\x1b[${agentRow};1H`);
170
-
171
- const onResize = () => {
172
- const newVr = getVisibleRows();
173
- const newScrollEnd = Math.max(4, newVr - 4);
174
- const newPromptRow = newScrollEnd + 1;
175
- process.stdout.write(
176
- "\x1b7" +
177
- "\x1b[r" +
178
- `\x1b[${promptRow};1H\x1b[J`,
179
- );
180
- drawStaticPrompt(newPromptRow, state);
181
- process.stdout.write(
182
- `\x1b[1;${newScrollEnd}r` +
183
- "\x1b8",
184
- );
185
- scrollEnd = newScrollEnd;
186
- promptRow = newPromptRow;
187
- };
188
- process.stdout.on("resize", onResize);
189
-
190
- const t0 = Date.now();
191
- let result;
192
- try {
193
- result = await runAgent({
194
- initialPrompt: line,
195
- priorMessages: state.messages.length > 0 ? state.messages : undefined,
196
- cwd: state.cwd,
197
- autoYes: state.autoYes,
198
- unsafePaths: state.unsafePaths,
199
- model: state.model,
200
- maxTurns: state.maxTurns,
201
- mcpManager,
202
- });
203
- } finally {
204
- process.stdout.removeListener("resize", onResize);
205
- process.stdout.write("\x1b[r");
206
- }
207
- const elapsed = Date.now() - t0;
160
+ state.turnCount++;
161
+ const result = await runAgent({
162
+ initialPrompt: line,
163
+ priorMessages: state.messages.length > 0 ? state.messages : undefined,
164
+ cwd: state.cwd,
165
+ autoYes: state.autoYes,
166
+ unsafePaths: state.unsafePaths,
167
+ model: state.model,
168
+ maxTurns: state.maxTurns,
169
+ mcpManager,
170
+ });
208
171
 
209
172
  state.sessionCredits += result.totalCredits ?? 0;
210
173
  state.sessionIn += result.totalIn ?? 0;
@@ -216,21 +179,29 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
216
179
  console.log("\n" + errorLine(result.error.message || String(result.error)));
217
180
  }
218
181
 
219
- const stats = turnStats({
220
- elapsed,
221
- tokensIn: 0,
222
- tokensOut: 0,
223
- tools: 0,
224
- credits: result.totalCredits ?? 0,
225
- });
226
- if (stats) console.log(stats);
227
- console.log("");
228
-
229
- // Position cursor at prompt area for the interactive prompt
230
- process.stdout.write(`\x1b[${promptRow};1H\x1b[J`);
182
+ autoSave(state);
183
+ printStatusLine(state);
231
184
  }
232
185
  }
233
186
 
187
+ function autoSave(state) {
188
+ if (state.messages.length < 2) return;
189
+ try {
190
+ saveSession({
191
+ id: state.sessionId,
192
+ cwd: state.cwd,
193
+ model: state.model,
194
+ modelName: state.modelName,
195
+ messages: state.messages,
196
+ credits: state.sessionCredits,
197
+ tokensIn: state.sessionIn,
198
+ tokensOut: state.sessionOut,
199
+ turnCount: state.turnCount,
200
+ summary: extractSummary(state.messages),
201
+ });
202
+ } catch { /* non-fatal — don't kill the session over a save failure */ }
203
+ }
204
+
234
205
  /* ───────── slash commands ───────── */
235
206
 
236
207
  async function handleSlash(line, state) {
@@ -259,10 +230,10 @@ async function handleSlash(line, state) {
259
230
  try {
260
231
  const me = await fetchBalance();
261
232
  state.balance = me.balance;
262
- state.plan = me.plan || "FREE";
233
+ state.plan = me.plan;
263
234
  console.log(
264
235
  c.cyan(`balance: ${me.balance.toLocaleString()} credits`) +
265
- c.gray(` \xB7 plan: ${me.plan || "FREE"} (${me.planCredits ?? 0} plan + ${me.topupCredits ?? 0} topup)`),
236
+ c.gray(` · plan: ${me.plan} (${me.planCredits} plan + ${me.topupCredits} topup)`),
266
237
  );
267
238
  } catch (err) {
268
239
  console.log(errorLine(err.message || String(err)));
@@ -275,7 +246,7 @@ async function handleSlash(line, state) {
275
246
  try {
276
247
  process.chdir(next);
277
248
  state.cwd = next;
278
- console.log(c.gray(`cwd -> ${state.cwd}`));
249
+ console.log(c.gray(`cwd → ${state.cwd}`));
279
250
  } catch (err) {
280
251
  console.log(errorLine(err.message));
281
252
  }
@@ -307,7 +278,7 @@ async function handleSlash(line, state) {
307
278
  state.modelName = MODEL_ALIASES[key].name;
308
279
  const premium = state.model !== null;
309
280
  console.log(
310
- c.gray(`model -> ${c.cyan(state.modelName)}`) +
281
+ c.gray(`model → ${c.cyan(state.modelName)}`) +
311
282
  (premium ? c.gray(" (premium — needs purchased credits, else falls back to Core)") : ""),
312
283
  );
313
284
  } else {
@@ -315,91 +286,132 @@ async function handleSlash(line, state) {
315
286
  }
316
287
  break;
317
288
  }
289
+ case "sessions": {
290
+ const sessions = listSessions(state.cwd);
291
+ if (sessions.length === 0) {
292
+ console.log(c.gray("No saved sessions for this directory."));
293
+ } else {
294
+ console.log(c.bold("Saved sessions") + c.gray(` (${state.cwd})`));
295
+ for (const s of sessions.slice(0, 10)) {
296
+ const active = s.id === state.sessionId ? c.cyan(" ← current") : "";
297
+ const summary = s.summary || "(no summary)";
298
+ const truncated = summary.length > 60 ? summary.slice(0, 57) + "..." : summary;
299
+ console.log(
300
+ ` ${c.gray(formatAge(s.updatedAt).padEnd(8))} ` +
301
+ `${c.gray(s.id.slice(0, 16))} ` +
302
+ `${truncated}` +
303
+ `${c.gray(` · ${s.turnCount} turns`)}` +
304
+ active,
305
+ );
306
+ }
307
+ if (sessions.length > 10) {
308
+ console.log(c.gray(` ... and ${sessions.length - 10} more`));
309
+ }
310
+ console.log(c.gray("\nUse /resume <id-prefix> to load one."));
311
+ }
312
+ break;
313
+ }
314
+ case "resume": {
315
+ const target = (arg || "").trim();
316
+ let session = null;
317
+ if (target) {
318
+ const sessions = listSessions(state.cwd);
319
+ const match = sessions.find((s) => s.id.startsWith(target));
320
+ if (match) session = loadSession(match.id);
321
+ if (!session) {
322
+ console.log(errorLine(`No session matching "${target}" found.`));
323
+ break;
324
+ }
325
+ } else {
326
+ const recent = findRecentSession(state.cwd);
327
+ if (recent) session = loadSession(recent.id);
328
+ if (!session) {
329
+ console.log(errorLine("No recent session to resume. Use /sessions to list."));
330
+ break;
331
+ }
332
+ }
333
+ autoSave(state);
334
+ state.sessionId = session.id;
335
+ state.messages = session.messages ?? [];
336
+ state.sessionCredits = session.credits ?? 0;
337
+ state.sessionIn = session.tokensIn ?? 0;
338
+ state.sessionOut = session.tokensOut ?? 0;
339
+ state.turnCount = session.turnCount ?? 0;
340
+ if (session.model !== undefined) state.model = session.model;
341
+ if (session.modelName) state.modelName = session.modelName;
342
+ const summary = extractSummary(session.messages) || "(no summary)";
343
+ console.log(c.gray(`Resumed: `) + c.cyan(summary));
344
+ console.log(c.gray(` ${state.messages.length} messages · ${state.turnCount} turns · ${formatAge(session.updatedAt)}`));
345
+ break;
346
+ }
347
+ case "new": {
348
+ autoSave(state);
349
+ state.sessionId = generateSessionId(state.cwd);
350
+ state.messages = [];
351
+ state.sessionCredits = 0;
352
+ state.sessionIn = 0;
353
+ state.sessionOut = 0;
354
+ state.turnCount = 0;
355
+ console.log(c.gray("New session started. Previous session saved."));
356
+ break;
357
+ }
318
358
  default:
319
359
  console.log(errorLine(`Unknown command: /${cmd}. Type ${c.cyan("/help")} for shortcuts.`));
320
360
  }
321
361
  return null;
322
362
  }
323
363
 
324
- /* ───────── banner (two-column like Claude Code) ───────── */
364
+ /* ───────── banner + status ───────── */
325
365
 
366
+ const visLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
326
367
  const shortenPath = (p, max) => (p.length <= max ? p : "..." + p.slice(p.length - max + 3));
327
368
 
328
- function padRight(str, width) {
329
- const visible = stripAnsi(str).length;
330
- const need = Math.max(0, width - visible);
331
- return str + " ".repeat(need);
332
- }
333
-
334
- function centerIn(str, width) {
335
- const visible = stripAnsi(str).length;
336
- if (visible >= width) return str;
337
- const left = Math.floor((width - visible) / 2);
338
- return " ".repeat(left) + str;
339
- }
369
+ // Short, hand-curated highlights shown in the "What's new" box. Update with
370
+ // notable releases — keep it to ~3 lines so it balances the banner height.
371
+ const WHATS_NEW = [
372
+ "Session persistence — close & resume",
373
+ "Project auto-understanding on startup",
374
+ "Live thinking spinner + loop guard",
375
+ ];
340
376
 
341
- function buildBannerLines(state) {
342
- const cols = process.stdout.columns || 80;
377
+ function printBanner(state, projectLine = "", resumed = false) {
378
+ const mode = state.autoYes ? (state.unsafePaths ? "skip-permissions" : "auto-yes") : "review mode";
343
379
  const credits = state.balance != null ? `${state.balance.toLocaleString()} credits` : "";
344
- const plan = state.plan && state.plan !== "FREE" ? state.plan : "";
345
-
346
- const blk = (code) => `\x1b[${code}m \x1b[0m`;
347
- const C = blk(46);
348
- const B = blk(44);
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(" " + padRight(left, leftW) + " " + VL + " " + right);
374
- };
380
+ const leftLines = [
381
+ c.bold(c.magenta("aether")) + c.gray(" · ") + c.bold(state.modelName) + c.gray(" v" + VERSION),
382
+ c.gray("uncensored AI coding agent"),
383
+ c.gray((credits ? credits + " · " : "") + mode),
384
+ c.gray(shortenPath(state.cwd, 52)),
385
+ ];
386
+ if (projectLine) {
387
+ leftLines.push(c.gray("project: " + (projectLine.length > 48 ? projectLine.slice(0, 45) + "..." : projectLine)));
388
+ }
389
+ if (resumed) {
390
+ leftLines.push(c.cyan("↻ resumed session") + c.gray(` · ${state.turnCount} turns · ${state.messages.length} msgs`));
391
+ }
392
+ const left = boxLines(leftLines, { color: c.magenta, padX: 2 });
393
+ const right = boxLines(
394
+ [c.bold(c.cyan("What's new")), ...WHATS_NEW.map((s) => c.gray("• " + s))],
395
+ { color: c.cyan, padX: 2 },
396
+ );
375
397
 
376
- row(centerIn(c.bold("Welcome back!"), leftW), c.bold("Tips for getting started"));
377
- row("", c.dim("Ask Aether to build, debug, or refactor code"));
378
- row(centerIn(`${C}${B} ${C}`, leftW), "");
379
- row(centerIn(`${B}${C} ${B}`, leftW), c.yellow("What's new"));
380
- row(centerIn(`${C}${B} ${C}`, leftW), c.dim("Creative thinking words while processing"));
381
- row("", c.dim("Prompt pins to bottom, content scrolls up"));
382
- row("", "");
383
- row(centerIn(modelLine, leftW), "");
384
- row(centerIn(c.dim(shortenPath(state.cwd, 50)), leftW), "");
398
+ console.log("");
399
+ const cols = process.stdout.columns || 80;
400
+ if (cols >= visLen(left[0]) + visLen(right[0]) + 4) {
401
+ console.log(sideBySide(left, right, 3));
385
402
  } else {
386
- lines.push("");
387
- lines.push(centerIn(c.bold("Welcome back!"), cols - 4));
388
- lines.push("");
389
- lines.push(centerIn(`${C}${B} ${C}`, cols - 4));
390
- lines.push(centerIn(`${B}${C} ${B}`, cols - 4));
391
- lines.push(centerIn(`${C}${B} ${C}`, cols - 4));
392
- lines.push("");
393
- lines.push(centerIn(modelLine, cols - 4));
394
- lines.push(centerIn(c.dim(shortenPath(state.cwd, 50)), cols - 4));
403
+ console.log(left.join("\n"));
395
404
  }
396
-
397
- lines.push(bottomRule);
398
- return lines;
405
+ console.log("");
399
406
  }
400
407
 
401
- function printBanner(state) {
402
- const lines = buildBannerLines(state);
403
- for (const l of lines) console.log(l);
404
- return lines.length;
408
+ function printStatusLine(state) {
409
+ const parts = [];
410
+ parts.push(c.gray(`session: ${state.sessionCredits} cr · ${state.sessionIn}→${state.sessionOut} tokens`));
411
+ if (state.balance != null) parts.push(c.gray(`balance: ${state.balance.toLocaleString()}`));
412
+ if (state.messages.length > 0) {
413
+ parts.push(c.gray(`history: ${state.messages.length} msg${state.messages.length === 1 ? "" : "s"}`));
414
+ }
415
+ parts.push(c.cyan(state.autoYes ? "auto-yes" : "review"));
416
+ console.log(c.dim(parts.join(" · ")));
405
417
  }