aether-code 0.42.0 → 0.43.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/bin/aether-code.js +515 -513
- package/package.json +77 -75
- package/scripts/postinstall.js +182 -0
- package/skills/adult-creative-writing.md +60 -60
- package/skills/debugging.md +51 -51
- package/skills/game-modding.md +73 -73
- package/skills/reverse-engineering.md +41 -41
- package/skills/scraping-automation.md +77 -77
- package/skills/security-research.md +67 -67
- package/src/agent.js +170 -219
- package/src/api.js +22 -32
- package/src/box-input.js +141 -36
- package/src/config.js +38 -38
- package/src/diff.js +49 -49
- package/src/ink-input.js +91 -91
- package/src/mcp-cli.js +94 -94
- package/src/mcp-registry.js +266 -266
- package/src/mcp.js +259 -260
- package/src/menu.js +83 -83
- package/src/render.js +141 -107
- package/src/repl.js +221 -257
- package/src/setup.js +139 -141
- package/src/tools.js +911 -1288
- package/src/update-check.js +64 -7
- package/src/project-context.js +0 -707
- package/src/sessions.js +0 -167
- package/src/version.js +0 -15
package/src/box-input.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Minimal prompt input — raw-mode, cmd.exe-safe.
|
|
2
|
+
// Bare "> " prompt with bottom status bar, matching Claude Code's layout.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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
|
-
|
|
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.
|
|
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;
|
|
73
|
+
let pos = 0;
|
|
32
74
|
const hist = history.slice();
|
|
33
|
-
let histIdx = hist.length;
|
|
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
|
|
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 =
|
|
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
|
-
|
|
46
|
-
const
|
|
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 +
|
|
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
|
-
|
|
54
|
-
|
|
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 <
|
|
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
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
+
}
|
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
|
+
}
|