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/api.js CHANGED
@@ -1,22 +1,9 @@
1
1
  // API client.
2
2
 
3
- import fs from "node:fs";
4
- import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
3
  import { getConfig } from "./config.js";
4
+ import { getVersion } from "./version.js";
7
5
 
8
- // Resolve our own version once for the User-Agent header — read from
9
- // package.json so it can't drift (the file previously sent three different
10
- // hardcoded versions across three calls).
11
- function readVersion() {
12
- try {
13
- const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
14
- return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version || "0";
15
- } catch {
16
- return "0";
17
- }
18
- }
19
- const USER_AGENT = `aether-code/${readVersion()}`;
6
+ const USER_AGENT = `aether-code/${getVersion()}`;
20
7
 
21
8
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
9
 
@@ -60,9 +47,12 @@ function defaultOnRetry(attempt, why) {
60
47
 
61
48
  /**
62
49
  * Free balance + plan check via /api/v1/me. Doesn't charge credits.
50
+ * Pass an explicit key to bypass the config chain (used during setup to
51
+ * verify the exact key the user entered, not whatever env var is set).
63
52
  */
64
- export async function fetchBalance() {
65
- const { apiKey, baseUrl } = getConfig();
53
+ export async function fetchBalance(apiKeyOverride) {
54
+ const { apiKey: configKey, baseUrl } = getConfig();
55
+ const apiKey = apiKeyOverride || configKey;
66
56
  if (!apiKey) {
67
57
  throw new AetherError(
68
58
  "No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
@@ -90,7 +80,7 @@ export async function fetchBalance() {
90
80
  const msg = data?.error || `${res.status} ${res.statusText}`;
91
81
  throw new AetherError(msg, code, res.status, data);
92
82
  }
93
- return data; // { plan, role, planCredits, topupCredits, balance, isSuspended, rate }
83
+ return data; // { plan, planCredits, topupCredits, balance, isSuspended }
94
84
  }
95
85
 
96
86
  export class AetherError extends Error {
package/src/box-input.js CHANGED
@@ -1,20 +1,15 @@
1
- // Minimal prompt input — raw-mode, cmd.exe-safe.
2
- // Bare "> " prompt with bottom status bar, matching Claude Code's layout.
1
+ // Bordered single-line text input — raw-mode, cmd.exe-safe.
3
2
  //
4
- // Features:
5
- // - Ctrl+V / Alt+V: paste image from clipboard
6
- // - @file.png: reference images by path (parsed by agent.js)
7
- // - Arrow keys, Home/End, Ctrl+A/E, Ctrl+U, history navigation
8
- // - Horizontal scroll for long input
3
+ // Like the ask_user menu, this is NOT Ink (which ghosts in the legacy Windows
4
+ // console). It draws a 3-line box + hint, and on every keystroke moves the
5
+ // cursor back up (ESC[nA), clears (ESC[J), and reprints. The caret is a FAKE
6
+ // cursor (inverse-video cell) and the real terminal cursor is hidden, so there
7
+ // is no cursor-column math to get wrong. Long input scrolls horizontally under
8
+ // a fixed right border.
9
9
  //
10
- // Returns the typed string (with @temppath appended for clipboard images),
11
- // or EXIT_SIGNAL on Ctrl+C / Ctrl+D.
10
+ // Returns the typed string, or EXIT_SIGNAL on Ctrl+C / Ctrl+D.
12
11
 
13
12
  import readline from "node:readline";
14
- import { execFileSync } from "node:child_process";
15
- import fs from "node:fs";
16
- import os from "node:os";
17
- import path from "node:path";
18
13
  import { c } from "./render.js";
19
14
 
20
15
  export const EXIT_SIGNAL = "<<aether-exit>>";
@@ -23,145 +18,68 @@ const HIDE = "\x1b[?25l";
23
18
  const SHOW = "\x1b[?25h";
24
19
  const INV = (s) => `\x1b[7m${s}\x1b[27m`;
25
20
 
26
- function grabClipboardImage() {
27
- const tmpFile = path.join(os.tmpdir(), `aether-paste-${Date.now()}.png`);
28
-
29
- try {
30
- if (process.platform === "win32") {
31
- const script =
32
- "Add-Type -AssemblyName System.Windows.Forms;" +
33
- "$img=[System.Windows.Forms.Clipboard]::GetImage();" +
34
- "if($img){$img.Save('" + tmpFile.replace(/'/g, "''") + "'," +
35
- "[System.Drawing.Imaging.ImageFormat]::Png);'OK'}else{'NO_IMAGE'}";
36
- const out = execFileSync("powershell.exe", [
37
- "-NoProfile", "-NonInteractive", "-Command", script,
38
- ], { timeout: 5000, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
39
- if (out.endsWith("OK") && fs.existsSync(tmpFile)) return tmpFile;
40
- } else if (process.platform === "darwin") {
41
- try {
42
- execFileSync("pngpaste", [tmpFile], { timeout: 3000, stdio: "pipe" });
43
- if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size > 0) return tmpFile;
44
- } catch { /* pngpaste not installed */ }
45
- } else {
46
- try {
47
- const fd = fs.openSync(tmpFile, "w");
48
- try {
49
- execFileSync("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"], {
50
- timeout: 3000, stdio: ["pipe", fd, "pipe"],
51
- });
52
- } finally {
53
- fs.closeSync(fd);
54
- }
55
- if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size > 0) return tmpFile;
56
- } catch { /* xclip not installed */ }
57
- }
58
- } catch { /* any failure — fall through */ }
59
-
60
- try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch { /* ignore */ }
61
- return null;
62
- }
63
-
64
- export function promptBox({ history = [], placeholder = "", mode = "review", suppressEcho = false, onResize = null } = {}) {
21
+ export function promptBox({ history = [], placeholder = "type a message… /help for commands" } = {}) {
65
22
  return new Promise((resolve) => {
23
+ // Non-interactive (piped / tests): plain readline, no box.
66
24
  if (!process.stdin.isTTY) {
67
25
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
68
- rl.question(c.cyan("> "), (a) => { rl.close(); resolve(a); });
26
+ rl.question(c.magenta("› "), (a) => { rl.close(); resolve(a); });
69
27
  return;
70
28
  }
71
29
 
72
30
  let buf = "";
73
- let pos = 0;
31
+ let pos = 0; // caret index within buf
74
32
  const hist = history.slice();
75
- let histIdx = hist.length;
33
+ let histIdx = hist.length; // points one past the end (current draft)
76
34
  let draft = "";
77
35
  let drawn = 0;
78
36
  let lastSigint = 0;
79
- const clipImages = [];
80
- let resizeHandler = null;
81
- let resizeTimer = null;
82
37
 
83
- const termW = () => Math.max(30, (process.stdout.columns || 80) - 2);
84
-
85
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
86
- const rowsFor = (line, cols) => Math.max(1, Math.ceil((stripAnsi(line).length || 1) / cols));
38
+ const innerW = () => Math.max(24, Math.min(process.stdout.columns || 80, 100) - 6);
87
39
 
88
40
  const draw = (first) => {
89
- const W = termW();
90
- const termCols = process.stdout.columns || 80;
41
+ const W = innerW();
91
42
  if (!first) process.stdout.write(`\x1b[${drawn}A`);
92
43
  process.stdout.write("\x1b[J");
93
44
 
94
- const prefixStr = "> ";
95
- const prefixLen = 2;
96
- const imgSuffix = clipImages.length > 0 ? ` [+${clipImages.length} img]` : "";
97
- const maxVisible = Math.max(8, W - prefixLen - imgSuffix.length);
45
+ // Horizontal scroll: keep the caret within the visible window.
46
+ const maxVisible = W - 1;
98
47
  const start = pos > maxVisible ? pos - maxVisible : 0;
99
- const view = buf.slice(start, start + maxVisible);
48
+ const view = buf.slice(start, start + W);
100
49
  const rel = pos - start;
101
50
 
102
51
  let content;
103
- if (buf.length === 0 && clipImages.length === 0) {
104
- content = INV(" ") + c.dim(placeholder.slice(0, maxVisible - 1));
105
- } else if (buf.length === 0 && clipImages.length > 0) {
106
- const tag = `[${clipImages.length} image${clipImages.length > 1 ? "s" : ""} pasted]`;
107
- content = INV(" ") + c.cyan(tag);
52
+ if (buf.length === 0) {
53
+ const ph = placeholder.slice(0, W - 1);
54
+ content = INV(" ") + c.dim(ph) + " ".repeat(Math.max(0, W - 1 - ph.length));
108
55
  } else {
109
56
  let s = "";
110
- for (let i = 0; i < maxVisible; i++) {
57
+ for (let i = 0; i < W; i++) {
111
58
  const ch = view[i] ?? " ";
112
59
  s += i === rel ? INV(ch) : ch;
113
60
  }
114
- if (imgSuffix) s += c.cyan(imgSuffix);
115
61
  content = s;
116
62
  }
117
63
 
118
- const promptLine = c.dim(prefixStr) + content;
119
-
120
- const blk = (code) => `\x1b[${code}m \x1b[0m`;
121
- const barPrefix = blk(46) + blk(44);
122
- const imgNote = clipImages.length > 0 ? c.cyan(" images queued") + c.dim(" \xB7 ") : "";
123
- const baseStatus = " " + mode + " mode \xB7 ? for shortcuts \xB7 @file for images";
124
- const barPrefixLen = 2;
125
- const imgNoteLen = clipImages.length > 0 ? 17 : 0;
126
- const maxStatusLen = Math.max(10, termCols - barPrefixLen - imgNoteLen - 1);
127
- const truncStatus = baseStatus.length > maxStatusLen ? baseStatus.slice(0, maxStatusLen) : baseStatus;
128
- const statusText = c.dim(truncStatus) + imgNote;
129
-
130
- const rule = c.cyan(" " + "─".repeat(Math.max(0, termCols - 2)));
131
- const lines = [rule, promptLine, rule, barPrefix + statusText];
132
- drawn = lines.reduce((sum, l) => sum + rowsFor(l, termCols), 0);
133
- process.stdout.write(lines.join("\n") + "\n");
64
+ const top = c.magenta("╭" + "─".repeat(W + 4) + "╮");
65
+ const mid = c.magenta("│") + " " + c.magenta("›") + " " + content + " " + c.magenta("│");
66
+ const bot = c.magenta("╰" + "─".repeat(W + 4) + "╯");
67
+ const hint = c.dim(" Enter send · ↑↓ history · /help · /exit");
68
+
69
+ process.stdout.write(top + "\n" + mid + "\n" + bot + "\n" + hint + "\n");
70
+ drawn = 4;
134
71
  };
135
72
 
136
73
  const finish = (result) => {
137
- if (resizeTimer) { clearTimeout(resizeTimer); resizeTimer = null; }
138
- if (resizeHandler) process.stdout.removeListener("resize", resizeHandler);
139
74
  process.stdin.removeListener("keypress", onKey);
140
75
  if (process.stdin.isTTY) process.stdin.setRawMode(false);
141
76
  process.stdin.pause();
142
77
  process.stdout.write(`\x1b[${drawn}A\x1b[J` + SHOW);
143
-
144
- let finalResult = result;
145
- if (result !== null && result !== EXIT_SIGNAL && clipImages.length > 0) {
146
- const userText = (result || "").trim();
147
- const imgRefs = clipImages.map((p) => `@"${p}"`).join(" ");
148
- finalResult = userText ? `${userText} ${imgRefs}` : `What is in this image? ${imgRefs}`;
78
+ // Echo the submitted line so it persists in scrollback.
79
+ if (result && result !== EXIT_SIGNAL && result.trim()) {
80
+ process.stdout.write(c.magenta("› ") + result + "\n");
149
81
  }
150
-
151
- if (!suppressEcho && finalResult && finalResult !== EXIT_SIGNAL) {
152
- const echoText = (result || "").trim();
153
- const imgTag = clipImages.length > 0
154
- ? ` [+${clipImages.length} image${clipImages.length > 1 ? "s" : ""}]`
155
- : "";
156
- if (echoText || clipImages.length > 0) {
157
- const displayText = echoText || "(image)";
158
- const cols = process.stdout.columns || 80;
159
- const textLen = displayText.length + imgTag.length + 2;
160
- const pad = Math.max(0, cols - textLen);
161
- process.stdout.write(`\x1b[48;5;236m ${displayText}${imgTag}${" ".repeat(pad)}\x1b[0m\n`);
162
- }
163
- }
164
- resolve(finalResult);
82
+ resolve(result);
165
83
  };
166
84
 
167
85
  const onKey = (str, key) => {
@@ -171,22 +89,13 @@ export function promptBox({ history = [], placeholder = "", mode = "review", sup
171
89
  if (k === "return" || k === "enter") return finish(buf);
172
90
  if (key.ctrl && k === "c") {
173
91
  const now = Date.now();
174
- if (now - lastSigint < 1500 || (buf.length === 0 && clipImages.length === 0)) return finish(EXIT_SIGNAL);
92
+ if (now - lastSigint < 1500 || buf.length === 0) return finish(EXIT_SIGNAL);
175
93
  lastSigint = now;
176
- buf = ""; pos = 0; clipImages.length = 0; draw(false);
94
+ buf = ""; pos = 0; draw(false);
177
95
  return;
178
96
  }
179
97
  if (key.ctrl && k === "d") return finish(buf.length === 0 ? EXIT_SIGNAL : buf);
180
98
 
181
- if ((key.meta && k === "v") || (key.ctrl && k === "v")) {
182
- const imgPath = grabClipboardImage();
183
- if (imgPath) {
184
- clipImages.push(imgPath);
185
- }
186
- draw(false);
187
- return;
188
- }
189
-
190
99
  if (k === "backspace") { if (pos > 0) { buf = buf.slice(0, pos - 1) + buf.slice(pos); pos--; } }
191
100
  else if (k === "delete") { buf = buf.slice(0, pos) + buf.slice(pos + 1); }
192
101
  else if (k === "left") { if (pos > 0) pos--; }
@@ -209,7 +118,7 @@ export function promptBox({ history = [], placeholder = "", mode = "review", sup
209
118
  buf = buf.slice(0, pos) + str + buf.slice(pos);
210
119
  pos += str.length;
211
120
  }
212
- else return;
121
+ else return; // ignore unhandled keys without a redraw
213
122
  draw(false);
214
123
  };
215
124
 
@@ -219,18 +128,5 @@ export function promptBox({ history = [], placeholder = "", mode = "review", sup
219
128
  process.stdin.resume();
220
129
  process.stdin.on("keypress", onKey);
221
130
  draw(true);
222
- resizeHandler = () => {
223
- if (resizeTimer) clearTimeout(resizeTimer);
224
- resizeTimer = setTimeout(() => {
225
- resizeTimer = null;
226
- if (onResize) {
227
- onResize();
228
- } else {
229
- process.stdout.write(`\x1b[${drawn}A\x1b[J`);
230
- }
231
- draw(true);
232
- }, 120);
233
- };
234
- process.stdout.on("resize", resizeHandler);
235
131
  });
236
132
  }
package/src/config.js CHANGED
@@ -1,38 +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;
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 CHANGED
@@ -1,49 +1,49 @@
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
- // No "@@ file @@" header — the "● write <file>" label already names the file.
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 writes don't flood the terminal — a short preview is enough
34
- // (skip-permissions auto-applies; the full file is on disk to inspect).
35
- const MAX = 14;
36
- if (lines.length > MAX) {
37
- const shown = lines.slice(0, MAX - 1);
38
- return [...shown, c.gray(` …${lines.length - shown.length} more lines (see the file)…`)].join("\n");
39
- }
40
- return lines.join("\n");
41
- }
42
-
43
- export function summarizeWrite(oldText, newText, filename) {
44
- const oldLines = (oldText || "").split("\n").length;
45
- const newLines = (newText || "").split("\n").length;
46
- const isCreate = oldText === null || oldText === undefined;
47
- const verb = isCreate ? "create" : "rewrite";
48
- return c.dim(`${verb} ${filename} (${oldLines} → ${newLines} lines, ${(newText || "").length} bytes)`);
49
- }
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
+ // No "@@ file @@" header — the "● write <file>" label already names the file.
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 writes don't flood the terminal — a short preview is enough
34
+ // (skip-permissions auto-applies; the full file is on disk to inspect).
35
+ const MAX = 14;
36
+ if (lines.length > MAX) {
37
+ const shown = lines.slice(0, MAX - 1);
38
+ return [...shown, c.gray(` …${lines.length - shown.length} more lines (see the file)…`)].join("\n");
39
+ }
40
+ return lines.join("\n");
41
+ }
42
+
43
+ export function summarizeWrite(oldText, newText, filename) {
44
+ const oldLines = (oldText || "").split("\n").length;
45
+ const newLines = (newText || "").split("\n").length;
46
+ const isCreate = oldText === null || oldText === undefined;
47
+ const verb = isCreate ? "create" : "rewrite";
48
+ return c.dim(`${verb} ${filename} (${oldLines} → ${newLines} lines, ${(newText || "").length} bytes)`);
49
+ }
package/src/ink-input.js CHANGED
@@ -1,91 +1,91 @@
1
- // Ink-based boxed input for the REPL — the bordered live input box (Claude
2
- // Code-style). Rendered fresh for each prompt, then unmounted so the agent's
3
- // plain console.log streaming during a turn never fights Ink for the terminal.
4
- //
5
- // Uses a `single`-line border (broadly supported in cmd.exe Consolas) rather
6
- // than rounded corners, which render as tofu on some Windows fonts.
7
- //
8
- // No JSX / no build step: components are built with React.createElement so the
9
- // package ships as-is. `InputApp` is exported (pure UI + callbacks) so it can be
10
- // unit-tested with ink-testing-library without a real TTY.
11
-
12
- import React, { useState, useRef } from "react";
13
- import { render, Box, Text, useApp, useInput } from "ink";
14
- import TextInput from "ink-text-input";
15
-
16
- const h = React.createElement;
17
-
18
- // Sentinel promptBoxed() resolves with on a double Ctrl+C, so the caller can
19
- // distinguish "user wants to quit" from an empty submitted line. Plain ASCII so
20
- // it can never be confused with a real keystroke or hide invisible chars.
21
- export const EXIT_SIGNAL = "<<aether-exit>>";
22
-
23
- /**
24
- * Pure input UI. Calls onSubmit(value) on Enter and onExit() on a double Ctrl+C.
25
- * Up/Down arrows walk `history` (newest last). No process/terminal side effects
26
- * of its own, so it's testable in isolation with ink-testing-library.
27
- */
28
- export function InputApp({ onSubmit, onExit, statusLeft = "", statusRight = "", history = [] }) {
29
- const [value, setValue] = useState("");
30
- const lastCtrlC = useRef(0);
31
- const histIdx = useRef(history.length);
32
-
33
- useInput((input, key) => {
34
- if (key.ctrl && input === "c") {
35
- const now = Date.now();
36
- if (now - lastCtrlC.current < 1500) onExit();
37
- else { lastCtrlC.current = now; setValue(""); }
38
- return;
39
- }
40
- if (key.upArrow) {
41
- if (histIdx.current > 0) { histIdx.current -= 1; setValue(history[histIdx.current] ?? ""); }
42
- } else if (key.downArrow) {
43
- if (histIdx.current < history.length - 1) { histIdx.current += 1; setValue(history[histIdx.current] ?? ""); }
44
- else { histIdx.current = history.length; setValue(""); }
45
- }
46
- });
47
-
48
- return h(
49
- Box,
50
- { flexDirection: "column", width: "100%" },
51
- h(
52
- Box,
53
- { borderStyle: "single", borderColor: "magenta", paddingX: 1 },
54
- h(Text, { color: "magenta", bold: true }, "> "),
55
- h(TextInput, { value, onChange: setValue, onSubmit, placeholder: "" }),
56
- ),
57
- h(
58
- Box,
59
- { paddingX: 1, width: "100%", justifyContent: "space-between" },
60
- h(Text, { dimColor: true }, statusLeft),
61
- h(Text, { dimColor: true }, statusRight),
62
- ),
63
- );
64
- }
65
-
66
- /**
67
- * Render a one-shot boxed input and resolve with the submitted line. Resolves
68
- * EXIT_SIGNAL on a double Ctrl+C; resolves "" if the app exits without a submit.
69
- */
70
- export function promptBoxed(opts = {}) {
71
- return new Promise((resolve) => {
72
- let done = false;
73
- let appApi = null;
74
- const fin = (val) => {
75
- if (done) return;
76
- done = true;
77
- try { if (appApi) appApi.exit(); } catch { /* already exiting */ }
78
- resolve(val);
79
- };
80
- const Wrapper = () => {
81
- appApi = useApp();
82
- return h(InputApp, {
83
- ...opts,
84
- onSubmit: (v) => fin(v),
85
- onExit: () => fin(EXIT_SIGNAL),
86
- });
87
- };
88
- const instance = render(h(Wrapper), { exitOnCtrlC: false });
89
- instance.waitUntilExit().then(() => { if (!done) { done = true; resolve(""); } });
90
- });
91
- }
1
+ // Ink-based boxed input for the REPL — the bordered live input box (Claude
2
+ // Code-style). Rendered fresh for each prompt, then unmounted so the agent's
3
+ // plain console.log streaming during a turn never fights Ink for the terminal.
4
+ //
5
+ // Uses a `single`-line border (broadly supported in cmd.exe Consolas) rather
6
+ // than rounded corners, which render as tofu on some Windows fonts.
7
+ //
8
+ // No JSX / no build step: components are built with React.createElement so the
9
+ // package ships as-is. `InputApp` is exported (pure UI + callbacks) so it can be
10
+ // unit-tested with ink-testing-library without a real TTY.
11
+
12
+ import React, { useState, useRef } from "react";
13
+ import { render, Box, Text, useApp, useInput } from "ink";
14
+ import TextInput from "ink-text-input";
15
+
16
+ const h = React.createElement;
17
+
18
+ // Sentinel promptBoxed() resolves with on a double Ctrl+C, so the caller can
19
+ // distinguish "user wants to quit" from an empty submitted line. Plain ASCII so
20
+ // it can never be confused with a real keystroke or hide invisible chars.
21
+ export const EXIT_SIGNAL = "<<aether-exit>>";
22
+
23
+ /**
24
+ * Pure input UI. Calls onSubmit(value) on Enter and onExit() on a double Ctrl+C.
25
+ * Up/Down arrows walk `history` (newest last). No process/terminal side effects
26
+ * of its own, so it's testable in isolation with ink-testing-library.
27
+ */
28
+ export function InputApp({ onSubmit, onExit, statusLeft = "", statusRight = "", history = [] }) {
29
+ const [value, setValue] = useState("");
30
+ const lastCtrlC = useRef(0);
31
+ const histIdx = useRef(history.length);
32
+
33
+ useInput((input, key) => {
34
+ if (key.ctrl && input === "c") {
35
+ const now = Date.now();
36
+ if (now - lastCtrlC.current < 1500) onExit();
37
+ else { lastCtrlC.current = now; setValue(""); }
38
+ return;
39
+ }
40
+ if (key.upArrow) {
41
+ if (histIdx.current > 0) { histIdx.current -= 1; setValue(history[histIdx.current] ?? ""); }
42
+ } else if (key.downArrow) {
43
+ if (histIdx.current < history.length - 1) { histIdx.current += 1; setValue(history[histIdx.current] ?? ""); }
44
+ else { histIdx.current = history.length; setValue(""); }
45
+ }
46
+ });
47
+
48
+ return h(
49
+ Box,
50
+ { flexDirection: "column", width: "100%" },
51
+ h(
52
+ Box,
53
+ { borderStyle: "single", borderColor: "magenta", paddingX: 1 },
54
+ h(Text, { color: "magenta", bold: true }, "> "),
55
+ h(TextInput, { value, onChange: setValue, onSubmit, placeholder: "" }),
56
+ ),
57
+ h(
58
+ Box,
59
+ { paddingX: 1, width: "100%", justifyContent: "space-between" },
60
+ h(Text, { dimColor: true }, statusLeft),
61
+ h(Text, { dimColor: true }, statusRight),
62
+ ),
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Render a one-shot boxed input and resolve with the submitted line. Resolves
68
+ * EXIT_SIGNAL on a double Ctrl+C; resolves "" if the app exits without a submit.
69
+ */
70
+ export function promptBoxed(opts = {}) {
71
+ return new Promise((resolve) => {
72
+ let done = false;
73
+ let appApi = null;
74
+ const fin = (val) => {
75
+ if (done) return;
76
+ done = true;
77
+ try { if (appApi) appApi.exit(); } catch { /* already exiting */ }
78
+ resolve(val);
79
+ };
80
+ const Wrapper = () => {
81
+ appApi = useApp();
82
+ return h(InputApp, {
83
+ ...opts,
84
+ onSubmit: (v) => fin(v),
85
+ onExit: () => fin(EXIT_SIGNAL),
86
+ });
87
+ };
88
+ const instance = render(h(Wrapper), { exitOnCtrlC: false });
89
+ instance.waitUntilExit().then(() => { if (!done) { done = true; resolve(""); } });
90
+ });
91
+ }