aether-code 0.3.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/api.js ADDED
@@ -0,0 +1,234 @@
1
+ // API client.
2
+
3
+ import { getConfig } from "./config.js";
4
+
5
+ /**
6
+ * Free balance + plan check via /api/v1/me. Doesn't charge credits.
7
+ */
8
+ export async function fetchBalance() {
9
+ const { apiKey, baseUrl } = getConfig();
10
+ if (!apiKey) {
11
+ throw new AetherError(
12
+ "No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
13
+ "NO_API_KEY",
14
+ 0,
15
+ );
16
+ }
17
+ let res;
18
+ try {
19
+ res = await fetch(`${baseUrl}/api/v1/me`, {
20
+ method: "GET",
21
+ headers: {
22
+ Accept: "application/json",
23
+ Authorization: `Bearer ${apiKey}`,
24
+ "User-Agent": "aether-code/0.2.0",
25
+ },
26
+ });
27
+ } catch (e) {
28
+ throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
29
+ }
30
+ let data = null;
31
+ try { data = await res.json(); } catch { /* non-JSON */ }
32
+ if (!res.ok) {
33
+ const code = data?.code || `HTTP_${res.status}`;
34
+ const msg = data?.error || `${res.status} ${res.statusText}`;
35
+ throw new AetherError(msg, code, res.status, data);
36
+ }
37
+ return data; // { plan, role, planCredits, topupCredits, balance, isSuspended, rate }
38
+ }
39
+
40
+ export class AetherError extends Error {
41
+ constructor(message, code, status, data) {
42
+ super(message);
43
+ this.name = "AetherError";
44
+ this.code = code;
45
+ this.status = status;
46
+ this.data = data;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Streaming agent turn. Calls /api/v1/agent/stream and invokes the supplied
52
+ * callbacks as events arrive. Returns the assembled assistant message + final
53
+ * usage/credit info once the stream ends.
54
+ *
55
+ * Event handlers (all optional):
56
+ * onDelta(text) — text fragment from the model
57
+ * onToolCallDelta(part) — partial tool call: { index, id?, name?, args_delta? }
58
+ * onFinish(reason) — per-choice finish reason ("stop" | "tool_calls" | "length")
59
+ *
60
+ * Returns:
61
+ * { message: { role:"assistant", content, tool_calls }, finish_reason,
62
+ * creditsCharged, balanceAfter, usage }
63
+ */
64
+ export async function agentTurnStream({
65
+ messages,
66
+ tools,
67
+ maxTokens,
68
+ temperature,
69
+ onDelta,
70
+ onToolCallDelta,
71
+ onFinish,
72
+ }) {
73
+ const { apiKey, baseUrl } = getConfig();
74
+ if (!apiKey) {
75
+ throw new AetherError(
76
+ "No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
77
+ "NO_API_KEY",
78
+ 0,
79
+ );
80
+ }
81
+
82
+ let res;
83
+ try {
84
+ res = await fetch(`${baseUrl}/api/v1/agent/stream`, {
85
+ method: "POST",
86
+ headers: {
87
+ "Content-Type": "application/json",
88
+ Accept: "text/event-stream",
89
+ Authorization: `Bearer ${apiKey}`,
90
+ "User-Agent": "aether-code/0.1.0",
91
+ },
92
+ body: JSON.stringify({
93
+ messages,
94
+ tools,
95
+ max_tokens: maxTokens,
96
+ temperature,
97
+ }),
98
+ });
99
+ } catch (e) {
100
+ throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
101
+ }
102
+
103
+ // Non-streaming JSON error response (auth, credit, validation failures)
104
+ if (!res.ok) {
105
+ let data = null;
106
+ try { data = await res.json(); } catch { /* non-JSON */ }
107
+ const code = data?.code || `HTTP_${res.status}`;
108
+ const msg = data?.error || `${res.status} ${res.statusText}`;
109
+ throw new AetherError(msg, code, res.status, data);
110
+ }
111
+ if (!res.body) throw new AetherError("Response has no body", "NO_BODY", res.status);
112
+
113
+ // Accumulate streaming state
114
+ let textBuf = "";
115
+ const toolBuf = new Map(); // index -> { id, name, args }
116
+ let finishReason = "stop";
117
+ let creditsCharged = 0;
118
+ let balanceAfter = null;
119
+ let usage = { prompt_tokens: 0, completion_tokens: 0 };
120
+ let streamError = null;
121
+
122
+ const reader = res.body.getReader();
123
+ const decoder = new TextDecoder();
124
+ let buffer = "";
125
+ while (true) {
126
+ const { value, done } = await reader.read();
127
+ if (done) break;
128
+ buffer += decoder.decode(value, { stream: true });
129
+ const lines = buffer.split("\n");
130
+ buffer = lines.pop() ?? "";
131
+ for (const line of lines) {
132
+ if (!line.startsWith("data:")) continue;
133
+ const json = line.slice(5).trim();
134
+ if (!json) continue;
135
+ let evt;
136
+ try { evt = JSON.parse(json); } catch { continue; }
137
+ if (evt.kind === "delta") {
138
+ textBuf += evt.content;
139
+ if (onDelta) onDelta(evt.content);
140
+ } else if (evt.kind === "tool_call_delta") {
141
+ const slot = toolBuf.get(evt.index) || { id: undefined, name: undefined, args: "" };
142
+ if (evt.id) slot.id = evt.id;
143
+ if (evt.name) slot.name = evt.name;
144
+ if (evt.args_delta) slot.args += evt.args_delta;
145
+ toolBuf.set(evt.index, slot);
146
+ if (onToolCallDelta) onToolCallDelta(evt);
147
+ } else if (evt.kind === "finish") {
148
+ finishReason = evt.reason;
149
+ if (onFinish) onFinish(evt.reason);
150
+ } else if (evt.kind === "done") {
151
+ creditsCharged = evt.creditsCharged ?? 0;
152
+ balanceAfter = evt.balanceAfter ?? null;
153
+ usage = evt.usage ?? usage;
154
+ } else if (evt.kind === "error") {
155
+ streamError = evt.error;
156
+ }
157
+ }
158
+ }
159
+
160
+ if (streamError) {
161
+ throw new AetherError(streamError, "STREAM_ERROR", 0);
162
+ }
163
+
164
+ // Assemble final tool_calls in stable index order
165
+ const tool_calls = [...toolBuf.entries()]
166
+ .sort((a, b) => a[0] - b[0])
167
+ .map(([, slot]) => ({
168
+ id: slot.id || `call_${Math.random().toString(36).slice(2, 10)}`,
169
+ type: "function",
170
+ function: { name: slot.name || "", arguments: slot.args || "" },
171
+ }));
172
+
173
+ return {
174
+ message: {
175
+ role: "assistant",
176
+ content: textBuf || null,
177
+ tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
178
+ },
179
+ finish_reason: finishReason,
180
+ creditsCharged,
181
+ balanceAfter,
182
+ usage,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Non-streaming variant — kept as a fallback for environments where SSE is
188
+ * problematic (corporate proxies, weird client setups). The CLI defaults to
189
+ * streaming.
190
+ */
191
+ export async function agentTurn({ messages, tools, maxTokens, temperature }) {
192
+ const { apiKey, baseUrl } = getConfig();
193
+ if (!apiKey) {
194
+ throw new AetherError(
195
+ "No API key. Set AETHER_API_KEY env var or run `aether-cli config set <key>`.",
196
+ "NO_API_KEY",
197
+ 0,
198
+ );
199
+ }
200
+
201
+ let res;
202
+ try {
203
+ res = await fetch(`${baseUrl}/api/v1/agent`, {
204
+ method: "POST",
205
+ headers: {
206
+ "Content-Type": "application/json",
207
+ Authorization: `Bearer ${apiKey}`,
208
+ "User-Agent": "aether-code/0.1.0",
209
+ },
210
+ body: JSON.stringify({
211
+ messages,
212
+ tools,
213
+ max_tokens: maxTokens,
214
+ temperature,
215
+ }),
216
+ });
217
+ } catch (e) {
218
+ throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
219
+ }
220
+
221
+ let data = null;
222
+ try {
223
+ data = await res.json();
224
+ } catch {
225
+ /* non-JSON */
226
+ }
227
+
228
+ if (!res.ok) {
229
+ const code = (data && data.code) || `HTTP_${res.status}`;
230
+ const msg = (data && data.error) || `${res.status} ${res.statusText}`;
231
+ throw new AetherError(msg, code, res.status, data);
232
+ }
233
+ return data;
234
+ }
package/src/config.js ADDED
@@ -0,0 +1,38 @@
1
+ // Config — reads ~/.aetherrc (shared format with aether-cli) and env vars.
2
+ // Same precedence: env wins over file.
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+
8
+ const FILE = path.join(os.homedir(), ".aetherrc");
9
+ const DEFAULT_BASE = "https://trynoguard.com";
10
+
11
+ export function readConfigFile() {
12
+ try {
13
+ const raw = fs.readFileSync(FILE, "utf8");
14
+ return JSON.parse(raw);
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+
20
+ export function writeConfigFile(patch) {
21
+ const current = readConfigFile();
22
+ const next = { ...current, ...patch };
23
+ // 0600 — readable only by the user. Mirrors what ssh, gnupg, etc. enforce
24
+ // on credential files. Treat the API key like an SSH key.
25
+ fs.writeFileSync(FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
26
+ try { fs.chmodSync(FILE, 0o600); } catch { /* windows ignores */ }
27
+ }
28
+
29
+ export function getConfig() {
30
+ const file = readConfigFile();
31
+ return {
32
+ apiKey: process.env.AETHER_API_KEY || file.apiKey || "",
33
+ baseUrl: (process.env.AETHER_BASE_URL || file.baseUrl || DEFAULT_BASE).replace(/\/+$/, ""),
34
+ configPath: FILE,
35
+ };
36
+ }
37
+
38
+ export const CONFIG_PATH = FILE;
package/src/diff.js ADDED
@@ -0,0 +1,48 @@
1
+ // Tiny line-by-line diff for write_file confirmation prompts.
2
+ // Not a "real" diff — just a side-by-side highlight of what's changing.
3
+ // Good enough for confirmation prompts, deliberately not pretending to be `git diff`.
4
+
5
+ import { c } from "./render.js";
6
+
7
+ export function unifiedDiff(oldText, newText, filename) {
8
+ const oldLines = (oldText || "").split("\n");
9
+ const newLines = (newText || "").split("\n");
10
+ const max = Math.max(oldLines.length, newLines.length);
11
+
12
+ // Find common prefix and suffix to keep the diff focused
13
+ let prefix = 0;
14
+ while (prefix < max && oldLines[prefix] === newLines[prefix]) prefix++;
15
+ let suffix = 0;
16
+ while (
17
+ suffix < max - prefix &&
18
+ oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
19
+ ) {
20
+ suffix++;
21
+ }
22
+
23
+ const changedOld = oldLines.slice(prefix, oldLines.length - suffix);
24
+ const changedNew = newLines.slice(prefix, newLines.length - suffix);
25
+
26
+ const lines = [];
27
+ lines.push(c.bold(c.cyan(`@@ ${filename} @@`)));
28
+ if (prefix > 0) lines.push(c.gray(` …${prefix} unchanged line${prefix === 1 ? "" : "s"} above…`));
29
+ for (const l of changedOld) lines.push(c.red(`- ${l}`));
30
+ for (const l of changedNew) lines.push(c.green(`+ ${l}`));
31
+ if (suffix > 0) lines.push(c.gray(` …${suffix} unchanged line${suffix === 1 ? "" : "s"} below…`));
32
+
33
+ // Cap output so massive writes don't flood the terminal
34
+ if (lines.length > 60) {
35
+ return [...lines.slice(0, 30), c.gray(` …${lines.length - 60} more lines hidden…`), ...lines.slice(-30)].join(
36
+ "\n",
37
+ );
38
+ }
39
+ return lines.join("\n");
40
+ }
41
+
42
+ export function summarizeWrite(oldText, newText, filename) {
43
+ const oldLines = (oldText || "").split("\n").length;
44
+ const newLines = (newText || "").split("\n").length;
45
+ const isCreate = oldText === null || oldText === undefined;
46
+ const verb = isCreate ? "create" : "rewrite";
47
+ return c.dim(`${verb} ${filename} (${oldLines} → ${newLines} lines, ${(newText || "").length} bytes)`);
48
+ }
package/src/render.js ADDED
@@ -0,0 +1,58 @@
1
+ // ANSI helpers — no chalk dependency.
2
+
3
+ const isTty = process.stdout.isTTY;
4
+ const noColor = !!process.env.NO_COLOR || !isTty;
5
+
6
+ function wrap(open, close) {
7
+ return (s) => (noColor ? String(s) : `\x1b[${open}m${s}\x1b[${close}m`);
8
+ }
9
+
10
+ export const c = {
11
+ bold: wrap(1, 22),
12
+ dim: wrap(2, 22),
13
+ red: wrap(31, 39),
14
+ green: wrap(32, 39),
15
+ yellow: wrap(33, 39),
16
+ blue: wrap(34, 39),
17
+ magenta: wrap(35, 39),
18
+ cyan: wrap(36, 39),
19
+ gray: wrap(90, 39),
20
+ };
21
+
22
+ export function divider() {
23
+ const w = process.stdout.columns || 60;
24
+ return c.gray("─".repeat(Math.min(60, w)));
25
+ }
26
+
27
+ export function turn(n) {
28
+ return c.gray(`turn ${n}`);
29
+ }
30
+
31
+ export function toolHeader(name, args) {
32
+ // Format args compactly. If any value is huge, truncate it.
33
+ const compact = JSON.stringify(args);
34
+ const trimmed = compact.length > 120 ? compact.slice(0, 117) + "..." : compact;
35
+ return `${c.cyan(c.bold(name))}${c.gray("(")}${c.gray(trimmed)}${c.gray(")")}`;
36
+ }
37
+
38
+ export function toolResult(text, ok = true) {
39
+ const prefix = ok ? c.green(" ✓ ") : c.red(" ✗ ");
40
+ // First line bold-ish, then dim continuation
41
+ const lines = text.split("\n");
42
+ const head = lines[0].slice(0, 200);
43
+ const rest = lines.slice(1, 6).join("\n").slice(0, 600);
44
+ return `${prefix}${head}${rest ? "\n" + c.dim(rest) : ""}`;
45
+ }
46
+
47
+ export function assistant(text) {
48
+ // Indent each line for visual separation from tool calls
49
+ return text.split("\n").map((l) => ` ${l}`).join("\n");
50
+ }
51
+
52
+ export function errorLine(msg) {
53
+ return `${c.red(c.bold("Error:"))} ${msg}`;
54
+ }
55
+
56
+ export function note(msg) {
57
+ return c.dim(msg);
58
+ }
package/src/repl.js ADDED
@@ -0,0 +1,246 @@
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 readline from "node:readline";
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 } from "./render.js";
17
+
18
+ const VERSION = "0.2.0";
19
+ const MODEL_NAME = "Aether Core";
20
+
21
+ const SHORTCUTS = `
22
+ ${c.cyan("/help")} Show this help
23
+ ${c.cyan("/exit")} Exit (or Ctrl+C twice)
24
+ ${c.cyan("/clear")} Clear conversation history (start fresh)
25
+ ${c.cyan("/balance")} Refresh and show credit balance
26
+ ${c.cyan("/cwd")} ${c.gray("[path]")} Show or change working directory
27
+ ${c.cyan("/yes")} Toggle auto-approve mode (skip y/N prompts)
28
+ ${c.cyan("/turns")} ${c.gray("<n>")} Set max turns per prompt (default 25)
29
+ ${c.cyan("/model")} Show current model
30
+
31
+ ${c.gray("Anything else is sent to the agent as your next message.")}
32
+ ${c.gray("Conversation history is kept across messages until you /clear.")}
33
+ `;
34
+
35
+ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, maxTurns: initialMaxTurns }) {
36
+ const state = {
37
+ cwd: initialCwd,
38
+ autoYes: !!initialAutoYes,
39
+ maxTurns: initialMaxTurns ?? 25,
40
+ messages: [], // accumulates across turns
41
+ balance: null,
42
+ sessionCredits: 0,
43
+ sessionIn: 0,
44
+ sessionOut: 0,
45
+ };
46
+
47
+ // Free balance check up front. If no key configured, walk through first-time
48
+ // setup flow (open browser → paste key → verify → save).
49
+ let needsSetup = false;
50
+ try {
51
+ const me = await fetchBalance();
52
+ state.balance = me.balance;
53
+ state.plan = me.plan;
54
+ } catch (err) {
55
+ if (err instanceof AetherError && (err.code === "NO_API_KEY" || err.status === 401)) {
56
+ needsSetup = true;
57
+ } else {
58
+ // Transient network or other — surface but don't block
59
+ console.log(c.gray(`(could not fetch balance: ${err.message})`));
60
+ }
61
+ }
62
+
63
+ if (needsSetup) {
64
+ const ok = await runSetup();
65
+ if (!ok) {
66
+ console.log(c.gray("Aborting — no valid API key."));
67
+ process.exit(1);
68
+ }
69
+ // Re-fetch balance now that the key is saved
70
+ try {
71
+ const me = await fetchBalance();
72
+ state.balance = me.balance;
73
+ state.plan = me.plan;
74
+ } catch { /* tolerate — banner just won't show balance */ }
75
+ }
76
+
77
+ printBanner(state);
78
+
79
+ const rl = readline.createInterface({
80
+ input: process.stdin,
81
+ output: process.stdout,
82
+ prompt: c.magenta("> "),
83
+ historySize: 200,
84
+ });
85
+
86
+ // Two-stage Ctrl+C: first cancels current line, second exits
87
+ let lastSigint = 0;
88
+ rl.on("SIGINT", () => {
89
+ const now = Date.now();
90
+ if (now - lastSigint < 1500) {
91
+ console.log(c.gray("\nbye."));
92
+ rl.close();
93
+ process.exit(0);
94
+ }
95
+ lastSigint = now;
96
+ console.log(c.gray(`\n(Press Ctrl+C again within 1.5s to exit, or type ${c.cyan("/exit")})`));
97
+ rl.prompt();
98
+ });
99
+
100
+ rl.prompt();
101
+
102
+ for await (const rawLine of rl) {
103
+ const line = rawLine.trim();
104
+ if (!line) {
105
+ rl.prompt();
106
+ continue;
107
+ }
108
+
109
+ // Slash command?
110
+ if (line.startsWith("/") || line === "?") {
111
+ const handled = await handleSlash(line, state);
112
+ if (handled === "exit") {
113
+ rl.close();
114
+ return;
115
+ }
116
+ printStatusLine(state);
117
+ rl.prompt();
118
+ continue;
119
+ }
120
+
121
+ // Otherwise — send as a message to the agent
122
+ const result = await runAgent({
123
+ initialPrompt: line,
124
+ priorMessages: state.messages.length > 0 ? state.messages : undefined,
125
+ cwd: state.cwd,
126
+ autoYes: state.autoYes,
127
+ maxTurns: state.maxTurns,
128
+ });
129
+
130
+ state.sessionCredits += result.totalCredits ?? 0;
131
+ state.sessionIn += result.totalIn ?? 0;
132
+ state.sessionOut += result.totalOut ?? 0;
133
+ if (typeof result.balance === "number") state.balance = result.balance;
134
+ if (result.messages) state.messages = result.messages;
135
+
136
+ if (!result.ok && result.error) {
137
+ console.log("\n" + errorLine(result.error.message || String(result.error)));
138
+ // Don't kill the session on a single error — surface it and continue.
139
+ }
140
+
141
+ printStatusLine(state);
142
+ rl.prompt();
143
+ }
144
+ }
145
+
146
+ /* ───────── slash commands ───────── */
147
+
148
+ async function handleSlash(line, state) {
149
+ const [cmd, ...rest] = line.replace(/^\//, "").split(/\s+/);
150
+ const arg = rest.join(" ").trim();
151
+
152
+ switch ((cmd || "").toLowerCase()) {
153
+ case "":
154
+ case "help":
155
+ case "?":
156
+ console.log(SHORTCUTS);
157
+ break;
158
+ case "exit":
159
+ case "quit":
160
+ case "q":
161
+ console.log(c.gray("bye."));
162
+ return "exit";
163
+ case "clear":
164
+ state.messages = [];
165
+ state.sessionCredits = 0;
166
+ state.sessionIn = 0;
167
+ state.sessionOut = 0;
168
+ console.log(c.gray("Conversation cleared."));
169
+ break;
170
+ case "balance": {
171
+ try {
172
+ const me = await fetchBalance();
173
+ state.balance = me.balance;
174
+ state.plan = me.plan;
175
+ console.log(
176
+ c.cyan(`balance: ${me.balance.toLocaleString()} credits`) +
177
+ c.gray(` · plan: ${me.plan} (${me.planCredits} plan + ${me.topupCredits} topup)`),
178
+ );
179
+ } catch (err) {
180
+ console.log(errorLine(err.message || String(err)));
181
+ }
182
+ break;
183
+ }
184
+ case "cwd":
185
+ if (arg) {
186
+ const next = path.resolve(arg);
187
+ try {
188
+ process.chdir(next);
189
+ state.cwd = next;
190
+ console.log(c.gray(`cwd → ${state.cwd}`));
191
+ } catch (err) {
192
+ console.log(errorLine(err.message));
193
+ }
194
+ } else {
195
+ console.log(c.gray(`cwd: ${state.cwd}`));
196
+ }
197
+ break;
198
+ case "yes":
199
+ state.autoYes = !state.autoYes;
200
+ console.log(c.gray(`auto-yes: ${state.autoYes ? "on (writes/shells will skip y/N)" : "off"}`));
201
+ break;
202
+ case "turns": {
203
+ const n = parseInt(arg, 10);
204
+ if (!Number.isFinite(n) || n < 1 || n > 200) {
205
+ console.log(errorLine(`/turns expects 1..200, got "${arg}"`));
206
+ } else {
207
+ state.maxTurns = n;
208
+ console.log(c.gray(`max turns: ${state.maxTurns}`));
209
+ }
210
+ break;
211
+ }
212
+ case "model":
213
+ console.log(c.gray(`model: ${MODEL_NAME} · 1M context · uncensored`));
214
+ break;
215
+ default:
216
+ console.log(errorLine(`Unknown command: /${cmd}. Type ${c.cyan("/help")} for shortcuts.`));
217
+ }
218
+ return null;
219
+ }
220
+
221
+ /* ───────── banner + status ───────── */
222
+
223
+ function printBanner(state) {
224
+ console.log("");
225
+ console.log(c.bold(c.magenta(`aether-code`)) + c.gray(` v${VERSION}`));
226
+ console.log(
227
+ c.gray(`${MODEL_NAME} (1M context) · uncensored · `) +
228
+ c.cyan(state.autoYes ? "auto-yes" : "review mode") +
229
+ (state.balance != null ? c.gray(` · ${state.balance.toLocaleString()} credits`) : ""),
230
+ );
231
+ console.log(c.gray(state.cwd));
232
+ console.log("");
233
+ console.log(c.gray(`Type ${c.cyan("/help")} for shortcuts. ${c.cyan("/exit")} or Ctrl+C twice to quit.`));
234
+ console.log("");
235
+ }
236
+
237
+ function printStatusLine(state) {
238
+ const parts = [];
239
+ parts.push(c.gray(`session: ${state.sessionCredits} cr · ${state.sessionIn}→${state.sessionOut} tokens`));
240
+ if (state.balance != null) parts.push(c.gray(`balance: ${state.balance.toLocaleString()}`));
241
+ if (state.messages.length > 0) {
242
+ parts.push(c.gray(`history: ${state.messages.length} msg${state.messages.length === 1 ? "" : "s"}`));
243
+ }
244
+ parts.push(c.cyan(state.autoYes ? "auto-yes" : "review"));
245
+ console.log(c.dim(parts.join(" · ")));
246
+ }