aether-code 0.43.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/api.js CHANGED
@@ -1,43 +1,37 @@
1
1
  // API client.
2
2
 
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
  import { getConfig } from "./config.js";
4
- import { getVersion } from "./version.js";
5
7
 
6
- const USER_AGENT = `aether-code/${getVersion()}`;
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()}`;
7
20
 
8
21
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
22
 
10
- // Per-request connect timeout. The server retries upstream internally (up to
11
- // 5 × 75s = 375s worst case), and during that time the CLI's fetch() just
12
- // hangs with zero feedback. This cap ensures we abort, show a retry message,
13
- // and try again rather than sitting dead for minutes.
14
- const CONNECT_TIMEOUT_MS = 90_000;
15
-
16
23
  /**
17
24
  * fetch() with bounded exponential-backoff retries. Retries on thrown network
18
25
  * errors (DNS/reset/offline blip) and transient 5xx; never retries 4xx
19
26
  * (auth/credit/validation — a retry won't help). Body is always a small JSON
20
27
  * string, so resending is safe. For the streaming endpoint only the initial
21
28
  * connection is retried.
22
- *
23
- * Each request has a hard 90s connect timeout via AbortController — if the
24
- * server hasn't responded with headers by then, we abort and retry. This
25
- * prevents the CLI from hanging indefinitely while the server retries
26
- * upstream internally.
27
29
  */
28
- export async function fetchWithRetry(url, options, { retries = 4, baseDelay = 1000, timeoutMs = CONNECT_TIMEOUT_MS, onRetry } = {}) {
30
+ export async function fetchWithRetry(url, options, { retries = 2, baseDelay = 500, onRetry } = {}) {
29
31
  let attempt = 0;
30
32
  while (true) {
31
- const ac = new AbortController();
32
- const timer = setTimeout(() => ac.abort(new Error(`Server did not respond within ${Math.round(timeoutMs / 1000)}s`)), timeoutMs);
33
- // If the caller already set a signal, chain it so both can abort.
34
- const callerSignal = options?.signal;
35
- if (callerSignal) {
36
- callerSignal.addEventListener("abort", () => ac.abort(callerSignal.reason), { once: true });
37
- }
38
33
  try {
39
- const res = await fetch(url, { ...options, signal: ac.signal });
40
- clearTimeout(timer);
34
+ const res = await fetch(url, options);
41
35
  if (res.status >= 500 && attempt < retries) {
42
36
  // Release the connection back to the pool before retrying — an
43
37
  // unconsumed body keeps the socket open (undici won't reuse it).
@@ -49,7 +43,6 @@ export async function fetchWithRetry(url, options, { retries = 4, baseDelay = 10
49
43
  }
50
44
  return res;
51
45
  } catch (e) {
52
- clearTimeout(timer);
53
46
  if (attempt < retries) {
54
47
  attempt++;
55
48
  if (onRetry) onRetry(attempt, e.message);
@@ -67,12 +60,9 @@ function defaultOnRetry(attempt, why) {
67
60
 
68
61
  /**
69
62
  * Free balance + plan check via /api/v1/me. Doesn't charge credits.
70
- * Pass an explicit key to bypass the config chain (used during setup to
71
- * verify the exact key the user entered, not whatever env var is set).
72
63
  */
73
- export async function fetchBalance(apiKeyOverride) {
74
- const { apiKey: configKey, baseUrl } = getConfig();
75
- const apiKey = apiKeyOverride || configKey;
64
+ export async function fetchBalance() {
65
+ const { apiKey, baseUrl } = getConfig();
76
66
  if (!apiKey) {
77
67
  throw new AetherError(
78
68
  "No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
@@ -100,7 +90,7 @@ export async function fetchBalance(apiKeyOverride) {
100
90
  const msg = data?.error || `${res.status} ${res.statusText}`;
101
91
  throw new AetherError(msg, code, res.status, data);
102
92
  }
103
- return data; // { plan, planCredits, topupCredits, balance, isSuspended }
93
+ return data; // { plan, role, planCredits, topupCredits, balance, isSuspended, rate }
104
94
  }
105
95
 
106
96
  export class AetherError extends Error {
@@ -222,12 +212,12 @@ export async function agentTurnStream({
222
212
 
223
213
  // Watchdog: if the open stream stalls (no bytes) for too long, abort instead
224
214
  // of hanging the CLI forever.
225
- const READ_TIMEOUT_MS = 180_000;
215
+ const READ_TIMEOUT_MS = 120_000;
226
216
  const readOnce = () => {
227
217
  let timer;
228
218
  const timeout = new Promise((_, reject) => {
229
219
  timer = setTimeout(
230
- () => reject(new AetherError(`Stream stalled — no data for ${Math.round(READ_TIMEOUT_MS / 1000)}s`, "STREAM_TIMEOUT", 0)),
220
+ () => reject(new AetherError("Stream stalled — no data for 120s", "STREAM_TIMEOUT", 0)),
231
221
  READ_TIMEOUT_MS,
232
222
  );
233
223
  });
package/src/box-input.js CHANGED
@@ -1,15 +1,20 @@
1
- // Bordered single-line text input — raw-mode, cmd.exe-safe.
1
+ // Minimal prompt input — raw-mode, cmd.exe-safe.
2
+ // Bare "> " prompt with bottom status bar, matching Claude Code's layout.
2
3
  //
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.
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
9
9
  //
10
- // Returns the typed string, or EXIT_SIGNAL on Ctrl+C / Ctrl+D.
10
+ // Returns the typed string (with @temppath appended for clipboard images),
11
+ // or EXIT_SIGNAL on Ctrl+C / Ctrl+D.
11
12
 
12
13
  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";
13
18
  import { c } from "./render.js";
14
19
 
15
20
  export const EXIT_SIGNAL = "<<aether-exit>>";
@@ -18,68 +23,146 @@ const HIDE = "\x1b[?25l";
18
23
  const SHOW = "\x1b[?25h";
19
24
  const INV = (s) => `\x1b[7m${s}\x1b[27m`;
20
25
 
21
- export function promptBox({ history = [], placeholder = "type a message… /help for commands" } = {}) {
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 } = {}) {
22
65
  return new Promise((resolve) => {
23
- // Non-interactive (piped / tests): plain readline, no box.
24
66
  if (!process.stdin.isTTY) {
25
67
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
26
- rl.question(c.magenta("› "), (a) => { rl.close(); resolve(a); });
68
+ rl.question(c.cyan("> "), (a) => { rl.close(); resolve(a); });
27
69
  return;
28
70
  }
29
71
 
30
72
  let buf = "";
31
- let pos = 0; // caret index within buf
73
+ let pos = 0;
32
74
  const hist = history.slice();
33
- let histIdx = hist.length; // points one past the end (current draft)
75
+ let histIdx = hist.length;
34
76
  let draft = "";
35
77
  let drawn = 0;
36
78
  let lastSigint = 0;
79
+ const clipImages = [];
80
+ let resizeHandler = null;
81
+ let resizeTimer = null;
37
82
 
38
- const innerW = () => Math.max(24, Math.min(process.stdout.columns || 80, 100) - 6);
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));
39
87
 
40
88
  const draw = (first) => {
41
- const W = innerW();
89
+ const W = termW();
90
+ const termCols = process.stdout.columns || 80;
42
91
  if (!first) process.stdout.write(`\x1b[${drawn}A`);
43
92
  process.stdout.write("\x1b[J");
44
93
 
45
- // Horizontal scroll: keep the caret within the visible window.
46
- const maxVisible = W - 1;
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);
47
98
  const start = pos > maxVisible ? pos - maxVisible : 0;
48
- const view = buf.slice(start, start + W);
99
+ const view = buf.slice(start, start + maxVisible);
49
100
  const rel = pos - start;
50
101
 
51
102
  let content;
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));
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);
55
108
  } else {
56
109
  let s = "";
57
- for (let i = 0; i < W; i++) {
110
+ for (let i = 0; i < maxVisible; i++) {
58
111
  const ch = view[i] ?? " ";
59
112
  s += i === rel ? INV(ch) : ch;
60
113
  }
114
+ if (imgSuffix) s += c.cyan(imgSuffix);
61
115
  content = s;
62
116
  }
63
117
 
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;
118
+ const promptLine = c.dim(prefixStr) + content;
119
+
120
+ const blk = (code) => `\x1b[${code}m \x1b[0m`;
121
+ const barPrefix = blk("48;2;209;107;52") + blk("48;2;0;178;169");
122
+ const imgNote = clipImages.length > 0 ? c.cyan(" images queued") + c.dim(" \xB7 ") : "";
123
+ const modeLabel = mode === "auto-yes" ? "auto-accept" : "confirm writes";
124
+ const baseStatus = " " + modeLabel + " \xB7 /help for shortcuts \xB7 @file to include";
125
+ const barPrefixLen = 2;
126
+ const imgNoteLen = clipImages.length > 0 ? 17 : 0;
127
+ const maxStatusLen = Math.max(10, termCols - barPrefixLen - imgNoteLen - 1);
128
+ const truncStatus = baseStatus.length > maxStatusLen ? baseStatus.slice(0, maxStatusLen) : baseStatus;
129
+ const statusText = c.dim(truncStatus) + imgNote;
130
+
131
+ const rule = c.cyan("─".repeat(Math.max(0, termCols)));
132
+ const lines = [rule, promptLine, rule, barPrefix + statusText];
133
+ drawn = lines.reduce((sum, l) => sum + rowsFor(l, termCols), 0);
134
+ process.stdout.write(lines.join("\n") + "\n");
71
135
  };
72
136
 
73
137
  const finish = (result) => {
138
+ if (resizeTimer) { clearTimeout(resizeTimer); resizeTimer = null; }
139
+ if (resizeHandler) process.stdout.removeListener("resize", resizeHandler);
74
140
  process.stdin.removeListener("keypress", onKey);
75
141
  if (process.stdin.isTTY) process.stdin.setRawMode(false);
76
142
  process.stdin.pause();
77
143
  process.stdout.write(`\x1b[${drawn}A\x1b[J` + SHOW);
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");
144
+
145
+ let finalResult = result;
146
+ if (result !== null && result !== EXIT_SIGNAL && clipImages.length > 0) {
147
+ const userText = (result || "").trim();
148
+ const imgRefs = clipImages.map((p) => `@"${p}"`).join(" ");
149
+ finalResult = userText ? `${userText} ${imgRefs}` : `What is in this image? ${imgRefs}`;
81
150
  }
82
- resolve(result);
151
+
152
+ if (!suppressEcho && finalResult && finalResult !== EXIT_SIGNAL) {
153
+ const echoText = (result || "").trim();
154
+ const imgTag = clipImages.length > 0
155
+ ? ` [+${clipImages.length} image${clipImages.length > 1 ? "s" : ""}]`
156
+ : "";
157
+ if (echoText || clipImages.length > 0) {
158
+ const displayText = echoText || "(image)";
159
+ const cols = process.stdout.columns || 80;
160
+ const textLen = displayText.length + imgTag.length + 2;
161
+ const pad = Math.max(0, cols - textLen);
162
+ process.stdout.write(`\x1b[48;5;236m ${displayText}${imgTag}${" ".repeat(pad)}\x1b[0m\n`);
163
+ }
164
+ }
165
+ resolve(finalResult);
83
166
  };
84
167
 
85
168
  const onKey = (str, key) => {
@@ -89,13 +172,22 @@ export function promptBox({ history = [], placeholder = "type a message… /hel
89
172
  if (k === "return" || k === "enter") return finish(buf);
90
173
  if (key.ctrl && k === "c") {
91
174
  const now = Date.now();
92
- if (now - lastSigint < 1500 || buf.length === 0) return finish(EXIT_SIGNAL);
175
+ if (now - lastSigint < 1500 || (buf.length === 0 && clipImages.length === 0)) return finish(EXIT_SIGNAL);
93
176
  lastSigint = now;
94
- buf = ""; pos = 0; draw(false);
177
+ buf = ""; pos = 0; clipImages.length = 0; draw(false);
95
178
  return;
96
179
  }
97
180
  if (key.ctrl && k === "d") return finish(buf.length === 0 ? EXIT_SIGNAL : buf);
98
181
 
182
+ if ((key.meta && k === "v") || (key.ctrl && k === "v")) {
183
+ const imgPath = grabClipboardImage();
184
+ if (imgPath) {
185
+ clipImages.push(imgPath);
186
+ }
187
+ draw(false);
188
+ return;
189
+ }
190
+
99
191
  if (k === "backspace") { if (pos > 0) { buf = buf.slice(0, pos - 1) + buf.slice(pos); pos--; } }
100
192
  else if (k === "delete") { buf = buf.slice(0, pos) + buf.slice(pos + 1); }
101
193
  else if (k === "left") { if (pos > 0) pos--; }
@@ -118,7 +210,7 @@ export function promptBox({ history = [], placeholder = "type a message… /hel
118
210
  buf = buf.slice(0, pos) + str + buf.slice(pos);
119
211
  pos += str.length;
120
212
  }
121
- else return; // ignore unhandled keys without a redraw
213
+ else return;
122
214
  draw(false);
123
215
  };
124
216
 
@@ -128,5 +220,18 @@ export function promptBox({ history = [], placeholder = "type a message… /hel
128
220
  process.stdin.resume();
129
221
  process.stdin.on("keypress", onKey);
130
222
  draw(true);
223
+ resizeHandler = () => {
224
+ if (resizeTimer) clearTimeout(resizeTimer);
225
+ resizeTimer = setTimeout(() => {
226
+ resizeTimer = null;
227
+ if (onResize) {
228
+ onResize();
229
+ } else {
230
+ process.stdout.write(`\x1b[${drawn}A\x1b[J`);
231
+ }
232
+ draw(true);
233
+ }, 120);
234
+ };
235
+ process.stdout.on("resize", resizeHandler);
131
236
  });
132
237
  }
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
+ }