aether-code 0.32.2 → 0.33.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/bin/aether-code.js +1 -1
- package/package.json +2 -2
- package/src/agent.js +119 -17
- package/src/box-input.js +209 -132
- package/src/render.js +45 -9
- package/src/repl.js +249 -291
- package/src/update-check.js +117 -60
package/bin/aether-code.js
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
import readline from "node:readline";
|
|
29
29
|
import { c, errorLine, divider, setTerminalTitle } from "../src/render.js";
|
|
30
30
|
|
|
31
|
-
const VERSION = "0.
|
|
31
|
+
const VERSION = "0.33.1";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Try to start MCP servers from ~/.aether/mcp.json. Returns a started
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aether-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.1",
|
|
4
4
|
"description": "Uncensored AI coding agent for your terminal — Claude Code alternative with MCP support. Reads code, writes files, runs commands. Drives IDA Pro, Roblox Studio, Wireshark, Blender, and any MCP server. No refusal layer.",
|
|
5
5
|
"homepage": "https://trynoguard.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"postinstall": "node scripts/postinstall.js",
|
|
28
|
-
"lint": "node --check bin/aether-code.js src/agent.js src/api.js src/config.js src/render.js src/tools.js src/diff.js src/repl.js src/mcp.js src/mcp-cli.js src/mcp-registry.js src/skills.js src/update-check.js src/ink-input.js scripts/postinstall.js",
|
|
28
|
+
"lint": "node --check bin/aether-code.js src/agent.js src/api.js src/config.js src/render.js src/tools.js src/diff.js src/repl.js src/mcp.js src/mcp-cli.js src/mcp-registry.js src/skills.js src/update-check.js src/box-input.js src/ink-input.js scripts/postinstall.js",
|
|
29
29
|
"test": "node --test \"test/**/*.test.js\"",
|
|
30
30
|
"prepublishOnly": "npm run lint && npm test"
|
|
31
31
|
},
|
package/src/agent.js
CHANGED
|
@@ -3,12 +3,78 @@
|
|
|
3
3
|
// tool calls (task done) or max-turns is reached.
|
|
4
4
|
|
|
5
5
|
import os from "node:os";
|
|
6
|
+
import fs from "node:fs";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import { agentTurnStream, AetherError } from "./api.js";
|
|
8
9
|
import { TOOL_DEFINITIONS, executeTool } from "./tools.js";
|
|
9
10
|
import { unnamespaceToolName } from "./mcp.js";
|
|
10
11
|
import { loadAllSkills, selectSkills, renderSkillsBlock } from "./skills.js";
|
|
11
|
-
import { c, divider, turn, toolLabel, toolSummary, makeTokenStripper, errorLine, startSpinner } from "./render.js";
|
|
12
|
+
import { c, divider, turn, toolLabel, toolSummary, makeTokenStripper, errorLine, startSpinner, turnStats, fmtTokens } from "./render.js";
|
|
13
|
+
|
|
14
|
+
/* ─────────────────────── Image attachment parsing ─────────────────────── */
|
|
15
|
+
|
|
16
|
+
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]);
|
|
17
|
+
const MIME_MAP = {
|
|
18
|
+
".png": "image/png",
|
|
19
|
+
".jpg": "image/jpeg",
|
|
20
|
+
".jpeg": "image/jpeg",
|
|
21
|
+
".gif": "image/gif",
|
|
22
|
+
".webp": "image/webp",
|
|
23
|
+
".bmp": "image/bmp",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Parse @path/to/image references from user input. Supports quoted paths for
|
|
27
|
+
// spaces: @"C:\Users\me\my screenshot.png". Returns { text, images }.
|
|
28
|
+
function parseImages(prompt, cwd) {
|
|
29
|
+
const re = /@("(?:[^"\\]|\\.)*"|[^\s]+)/g;
|
|
30
|
+
const images = [];
|
|
31
|
+
let cleaned = prompt;
|
|
32
|
+
|
|
33
|
+
for (const m of [...prompt.matchAll(re)]) {
|
|
34
|
+
const raw = m[1].replace(/^"|"$/g, "");
|
|
35
|
+
const ext = path.extname(raw).toLowerCase();
|
|
36
|
+
if (!IMAGE_EXTS.has(ext)) continue;
|
|
37
|
+
|
|
38
|
+
const abs = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
|
|
39
|
+
try {
|
|
40
|
+
const buf = fs.readFileSync(abs);
|
|
41
|
+
if (buf.length > 20 * 1024 * 1024) {
|
|
42
|
+
console.log(c.yellow(` ⚠ Image too large (>20MB), skipping: ${raw}`));
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const mime = MIME_MAP[ext] || "image/png";
|
|
46
|
+
images.push({
|
|
47
|
+
url: `data:${mime};base64,${buf.toString("base64")}`,
|
|
48
|
+
name: path.basename(raw),
|
|
49
|
+
size: buf.length,
|
|
50
|
+
});
|
|
51
|
+
cleaned = cleaned.replace(m[0], "");
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.log(c.yellow(` ⚠ Could not read image: ${raw} — ${e.code === "ENOENT" ? "file not found" : e.message}`));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { text: cleaned.replace(/\s{2,}/g, " ").trim() || prompt.trim(), images };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build OpenAI-compatible content array with text + images, or plain string
|
|
61
|
+
// when no images are attached.
|
|
62
|
+
function buildContent(text, images) {
|
|
63
|
+
if (images.length === 0) return text;
|
|
64
|
+
const parts = [];
|
|
65
|
+
for (const img of images) {
|
|
66
|
+
parts.push({ type: "image_url", image_url: { url: img.url } });
|
|
67
|
+
}
|
|
68
|
+
parts.push({ type: "text", text });
|
|
69
|
+
return parts;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Friendly file size: 1.2MB, 450KB, 128B
|
|
73
|
+
function fmtSize(bytes) {
|
|
74
|
+
if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(1) + "MB";
|
|
75
|
+
if (bytes >= 1024) return (bytes / 1024).toFixed(0) + "KB";
|
|
76
|
+
return bytes + "B";
|
|
77
|
+
}
|
|
12
78
|
|
|
13
79
|
const DEFAULT_MAX_TURNS = 25;
|
|
14
80
|
|
|
@@ -76,6 +142,15 @@ export async function runAgent({
|
|
|
76
142
|
process.stderr.write(c.yellow(`(skill load failed: ${e.message})\n`));
|
|
77
143
|
}
|
|
78
144
|
const referencedPaths = [];
|
|
145
|
+
|
|
146
|
+
// Parse image attachments (@path/to/image.png) from the user's prompt.
|
|
147
|
+
const { text: cleanPrompt, images: attachedImages } = parseImages(initialPrompt, cwd);
|
|
148
|
+
if (attachedImages.length > 0) {
|
|
149
|
+
for (const img of attachedImages) {
|
|
150
|
+
console.log(`\n${c.cyan("●")} ${c.cyan(c.bold("image"))} ${c.gray(img.name)} ${c.gray(`(${fmtSize(img.size)})`)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
79
154
|
// Two callers: one-shot (initialPrompt only, fresh conversation) and REPL
|
|
80
155
|
// (priorMessages + initialPrompt to continue an ongoing chat).
|
|
81
156
|
// On the FIRST message of a session, prepend an environment block so the
|
|
@@ -83,9 +158,11 @@ export async function runAgent({
|
|
|
83
158
|
// X on my desktop" became `mkdir X` in whatever dir aether was launched from
|
|
84
159
|
// (e.g. C:\WINDOWS\system32). Only prepended once — later turns carry it in
|
|
85
160
|
// history.
|
|
161
|
+
const firstText = priorMessages ? cleanPrompt : envContext(cwd) + cleanPrompt;
|
|
162
|
+
const firstContent = buildContent(firstText, attachedImages);
|
|
86
163
|
const messages = priorMessages
|
|
87
|
-
? [...priorMessages, { role: "user", content:
|
|
88
|
-
: [{ role: "user", content:
|
|
164
|
+
? [...priorMessages, { role: "user", content: firstContent }]
|
|
165
|
+
: [{ role: "user", content: firstContent }];
|
|
89
166
|
let totalCredits = 0;
|
|
90
167
|
let totalIn = 0;
|
|
91
168
|
let totalOut = 0;
|
|
@@ -108,15 +185,14 @@ export async function runAgent({
|
|
|
108
185
|
let appliedNothingNudges = 0;
|
|
109
186
|
|
|
110
187
|
for (let i = 0; i < maxTurns; i++) {
|
|
111
|
-
|
|
112
|
-
// each tool label) begins with its own "\n● ", so spacing stays exactly one
|
|
113
|
-
// blank line per step instead of stacking up.
|
|
188
|
+
const turnStart = Date.now();
|
|
114
189
|
|
|
115
190
|
// Stream the assistant's response. Print text deltas as they arrive,
|
|
116
191
|
// along with tool-call announcements as soon as the model commits to
|
|
117
192
|
// calling a particular tool (i.e. the `name` arrives in the stream).
|
|
118
193
|
const announced = new Set();
|
|
119
194
|
let lastWasText = false;
|
|
195
|
+
let streamedChars = 0;
|
|
120
196
|
const stripper = makeTokenStripper();
|
|
121
197
|
|
|
122
198
|
// Select skills for this turn against the current user prompt + any
|
|
@@ -138,6 +214,8 @@ export async function runAgent({
|
|
|
138
214
|
tools,
|
|
139
215
|
model,
|
|
140
216
|
onDelta: (text) => {
|
|
217
|
+
streamedChars += text.length;
|
|
218
|
+
spinner.update({ tokens: Math.ceil(streamedChars / 3.5) });
|
|
141
219
|
// Buffered strip of leaked model channel/control tokens (which can
|
|
142
220
|
// be split across stream chunks) before display.
|
|
143
221
|
const clean = stripper.push(text);
|
|
@@ -180,13 +258,13 @@ export async function runAgent({
|
|
|
180
258
|
process.stdout.write(tail);
|
|
181
259
|
}
|
|
182
260
|
if (lastWasText) process.stdout.write("\n");
|
|
261
|
+
const turnIn = res.usage?.prompt_tokens ?? 0;
|
|
262
|
+
const turnOut = res.usage?.completion_tokens ?? 0;
|
|
183
263
|
totalCredits += res.creditsCharged ?? 0;
|
|
184
|
-
totalIn +=
|
|
185
|
-
totalOut +=
|
|
264
|
+
totalIn += turnIn;
|
|
265
|
+
totalOut += turnOut;
|
|
186
266
|
if (typeof res.balanceAfter === "number") lastBalance = res.balanceAfter;
|
|
187
267
|
onTokens({ totalCredits, totalIn, totalOut, balance: lastBalance });
|
|
188
|
-
// Per-turn cost line removed for a cleaner look — the session summary at the
|
|
189
|
-
// end carries the totals.
|
|
190
268
|
|
|
191
269
|
// Push assistant message into history
|
|
192
270
|
messages.push({
|
|
@@ -289,6 +367,16 @@ export async function runAgent({
|
|
|
289
367
|
content: result.output ?? (result.ok ? "(no output)" : "Failed."),
|
|
290
368
|
});
|
|
291
369
|
}
|
|
370
|
+
|
|
371
|
+
// Per-turn stats line — shows elapsed time, token flow, and tool count.
|
|
372
|
+
const stats = turnStats({
|
|
373
|
+
elapsed: Date.now() - turnStart,
|
|
374
|
+
tokensIn: turnIn,
|
|
375
|
+
tokensOut: turnOut,
|
|
376
|
+
tools: toolCalls.length,
|
|
377
|
+
credits: res.creditsCharged ?? 0,
|
|
378
|
+
});
|
|
379
|
+
if (stats) console.log(stats);
|
|
292
380
|
}
|
|
293
381
|
|
|
294
382
|
console.log(c.yellow(`\nReached max turns (${maxTurns}). Stopping.`));
|
|
@@ -317,16 +405,30 @@ function buildTurnMessages(messages, allSkills, referencedPaths) {
|
|
|
317
405
|
if (messages[i].role === "user") { lastUserIdx = i; break; }
|
|
318
406
|
}
|
|
319
407
|
if (lastUserIdx === -1) return messages;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
408
|
+
// Content can be a string or a multimodal array (when images are attached).
|
|
409
|
+
// Extract the text portion for skill selection; prepend skills to text only.
|
|
410
|
+
const rawContent = messages[lastUserIdx].content;
|
|
411
|
+
let prompt;
|
|
412
|
+
if (typeof rawContent === "string") {
|
|
413
|
+
prompt = rawContent;
|
|
414
|
+
} else if (Array.isArray(rawContent)) {
|
|
415
|
+
const textPart = rawContent.find((p) => p.type === "text");
|
|
416
|
+
prompt = textPart?.text ?? "";
|
|
417
|
+
} else {
|
|
418
|
+
prompt = "";
|
|
419
|
+
}
|
|
323
420
|
const active = selectSkills({ skills: allSkills, prompt, referencedPaths });
|
|
324
421
|
if (active.length === 0) return messages;
|
|
325
422
|
const block = renderSkillsBlock(active);
|
|
326
423
|
const cloned = [...messages];
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
content: `${block}\n\n---\n\n${prompt}
|
|
330
|
-
}
|
|
424
|
+
// Inject skill block into the text portion of the content.
|
|
425
|
+
if (typeof rawContent === "string") {
|
|
426
|
+
cloned[lastUserIdx] = { ...cloned[lastUserIdx], content: `${block}\n\n---\n\n${prompt}` };
|
|
427
|
+
} else if (Array.isArray(rawContent)) {
|
|
428
|
+
const newParts = rawContent.map((p) =>
|
|
429
|
+
p.type === "text" ? { ...p, text: `${block}\n\n---\n\n${p.text}` } : p,
|
|
430
|
+
);
|
|
431
|
+
cloned[lastUserIdx] = { ...cloned[lastUserIdx], content: newParts };
|
|
432
|
+
}
|
|
331
433
|
return cloned;
|
|
332
434
|
}
|
package/src/box-input.js
CHANGED
|
@@ -1,132 +1,209 @@
|
|
|
1
|
-
// Bordered single-line text input — raw-mode, cmd.exe-safe.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
import readline from "node:readline";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
else
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
1
|
+
// Bordered single-line text input — raw-mode, cmd.exe-safe.
|
|
2
|
+
//
|
|
3
|
+
// Features:
|
|
4
|
+
// - Ctrl+V / Alt+V: paste image from clipboard (saves to temp, attaches)
|
|
5
|
+
// - @file.png: reference images by path (parsed by agent.js)
|
|
6
|
+
// - Arrow keys, Home/End, Ctrl+A/E, Ctrl+U, history navigation
|
|
7
|
+
// - Horizontal scroll for long input
|
|
8
|
+
//
|
|
9
|
+
// Returns the typed string (with @temppath appended for clipboard images),
|
|
10
|
+
// or EXIT_SIGNAL on Ctrl+C / Ctrl+D.
|
|
11
|
+
|
|
12
|
+
import readline from "node:readline";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { c } from "./render.js";
|
|
18
|
+
|
|
19
|
+
export const EXIT_SIGNAL = "<<aether-exit>>";
|
|
20
|
+
|
|
21
|
+
const HIDE = "\x1b[?25l";
|
|
22
|
+
const SHOW = "\x1b[?25h";
|
|
23
|
+
const INV = (s) => `\x1b[7m${s}\x1b[27m`;
|
|
24
|
+
|
|
25
|
+
// Save clipboard image to a temp file. Returns the file path or null.
|
|
26
|
+
// Platform-specific: PowerShell on Windows, pngpaste on macOS, xclip on Linux.
|
|
27
|
+
function grabClipboardImage() {
|
|
28
|
+
const tmpFile = path.join(os.tmpdir(), `aether-paste-${Date.now()}.png`);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
if (process.platform === "win32") {
|
|
32
|
+
const script =
|
|
33
|
+
"Add-Type -AssemblyName System.Windows.Forms;" +
|
|
34
|
+
"$img=[System.Windows.Forms.Clipboard]::GetImage();" +
|
|
35
|
+
"if($img){$img.Save('" + tmpFile.replace(/'/g, "''") + "'," +
|
|
36
|
+
"[System.Drawing.Imaging.ImageFormat]::Png);'OK'}else{'NO_IMAGE'}";
|
|
37
|
+
const out = execFileSync("powershell.exe", [
|
|
38
|
+
"-NoProfile", "-NonInteractive", "-Command", script,
|
|
39
|
+
], { timeout: 5000, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
40
|
+
if (out.endsWith("OK") && fs.existsSync(tmpFile)) return tmpFile;
|
|
41
|
+
} else if (process.platform === "darwin") {
|
|
42
|
+
try {
|
|
43
|
+
execFileSync("pngpaste", [tmpFile], { timeout: 3000, stdio: "pipe" });
|
|
44
|
+
if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size > 0) return tmpFile;
|
|
45
|
+
} catch { /* pngpaste not installed — hint below */ }
|
|
46
|
+
} else {
|
|
47
|
+
// Linux: xclip
|
|
48
|
+
try {
|
|
49
|
+
const fd = fs.openSync(tmpFile, "w");
|
|
50
|
+
try {
|
|
51
|
+
execFileSync("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"], {
|
|
52
|
+
timeout: 3000, stdio: ["pipe", fd, "pipe"],
|
|
53
|
+
});
|
|
54
|
+
} finally {
|
|
55
|
+
fs.closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size > 0) return tmpFile;
|
|
58
|
+
} catch { /* xclip not installed */ }
|
|
59
|
+
}
|
|
60
|
+
} catch { /* any failure — fall through */ }
|
|
61
|
+
|
|
62
|
+
// Clean up empty/failed temp file
|
|
63
|
+
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch { /* ignore */ }
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function promptBox({ history = [], placeholder = "Message Aether…" } = {}) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
// Non-interactive (piped / tests): plain readline, no box.
|
|
70
|
+
if (!process.stdin.isTTY) {
|
|
71
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
72
|
+
rl.question(c.cyan("> "), (a) => { rl.close(); resolve(a); });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let buf = "";
|
|
77
|
+
let pos = 0;
|
|
78
|
+
const hist = history.slice();
|
|
79
|
+
let histIdx = hist.length;
|
|
80
|
+
let draft = "";
|
|
81
|
+
let drawn = 0;
|
|
82
|
+
let lastSigint = 0;
|
|
83
|
+
const clipImages = []; // temp file paths from clipboard pastes
|
|
84
|
+
|
|
85
|
+
const innerW = () => Math.max(24, Math.min(process.stdout.columns || 80, 100) - 6);
|
|
86
|
+
|
|
87
|
+
const draw = (first) => {
|
|
88
|
+
const W = innerW();
|
|
89
|
+
if (!first) process.stdout.write(`\x1b[${drawn}A`);
|
|
90
|
+
process.stdout.write("\x1b[J");
|
|
91
|
+
|
|
92
|
+
// Horizontal scroll: keep the caret within the visible window.
|
|
93
|
+
const maxVisible = W - 1;
|
|
94
|
+
const start = pos > maxVisible ? pos - maxVisible : 0;
|
|
95
|
+
const view = buf.slice(start, start + W);
|
|
96
|
+
const rel = pos - start;
|
|
97
|
+
|
|
98
|
+
let content;
|
|
99
|
+
if (buf.length === 0) {
|
|
100
|
+
const ph = placeholder.slice(0, W - 1);
|
|
101
|
+
content = INV(" ") + c.dim(ph) + " ".repeat(Math.max(0, W - 1 - ph.length));
|
|
102
|
+
} else {
|
|
103
|
+
let s = "";
|
|
104
|
+
for (let i = 0; i < W; i++) {
|
|
105
|
+
const ch = view[i] ?? " ";
|
|
106
|
+
s += i === rel ? INV(ch) : ch;
|
|
107
|
+
}
|
|
108
|
+
content = s;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const top = c.cyan("╭" + "─".repeat(W + 4) + "╮");
|
|
112
|
+
const mid = c.cyan("│") + " " + c.cyan(">") + " " + content + " " + c.cyan("│");
|
|
113
|
+
const bot = c.cyan("╰" + "─".repeat(W + 4) + "╯");
|
|
114
|
+
|
|
115
|
+
// Hint line — show paste indicator when images are queued
|
|
116
|
+
const imgTag = clipImages.length > 0
|
|
117
|
+
? " " + c.cyan(`[${clipImages.length} image${clipImages.length > 1 ? "s" : ""} pasted]`)
|
|
118
|
+
: "";
|
|
119
|
+
const hint = c.dim(" Enter send · Alt+V paste image · @file · /help") + imgTag;
|
|
120
|
+
|
|
121
|
+
process.stdout.write(top + "\n" + mid + "\n" + bot + "\n" + hint + "\n");
|
|
122
|
+
drawn = 4;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const finish = (result) => {
|
|
126
|
+
process.stdin.removeListener("keypress", onKey);
|
|
127
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
128
|
+
process.stdin.pause();
|
|
129
|
+
process.stdout.write(`\x1b[${drawn}A\x1b[J` + SHOW);
|
|
130
|
+
|
|
131
|
+
// Append clipboard image temp paths so agent.js's @-parser picks them up.
|
|
132
|
+
let finalResult = result;
|
|
133
|
+
if (result !== null && result !== EXIT_SIGNAL && clipImages.length > 0) {
|
|
134
|
+
const userText = (result || "").trim();
|
|
135
|
+
const imgRefs = clipImages.map((p) => `@"${p}"`).join(" ");
|
|
136
|
+
finalResult = userText ? `${userText} ${imgRefs}` : `What is in this image? ${imgRefs}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Echo the submitted line so it persists in scrollback.
|
|
140
|
+
if (finalResult && finalResult !== EXIT_SIGNAL) {
|
|
141
|
+
const echoText = (result || "").trim();
|
|
142
|
+
const suffix = clipImages.length > 0
|
|
143
|
+
? c.cyan(` [+${clipImages.length} image${clipImages.length > 1 ? "s" : ""}]`)
|
|
144
|
+
: "";
|
|
145
|
+
if (echoText || clipImages.length > 0) {
|
|
146
|
+
process.stdout.write(c.cyan("> ") + (echoText || c.dim("(image)")) + suffix + "\n");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
resolve(finalResult);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const onKey = (str, key) => {
|
|
153
|
+
if (!key) return;
|
|
154
|
+
const k = key.name;
|
|
155
|
+
|
|
156
|
+
if (k === "return" || k === "enter") return finish(buf);
|
|
157
|
+
if (key.ctrl && k === "c") {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
if (now - lastSigint < 1500 || (buf.length === 0 && clipImages.length === 0)) return finish(EXIT_SIGNAL);
|
|
160
|
+
lastSigint = now;
|
|
161
|
+
buf = ""; pos = 0; clipImages.length = 0; draw(false);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (key.ctrl && k === "d") return finish(buf.length === 0 ? EXIT_SIGNAL : buf);
|
|
165
|
+
|
|
166
|
+
// Alt+V or Ctrl+V: paste image from clipboard
|
|
167
|
+
if ((key.meta && k === "v") || (key.ctrl && k === "v")) {
|
|
168
|
+
const imgPath = grabClipboardImage();
|
|
169
|
+
if (imgPath) {
|
|
170
|
+
clipImages.push(imgPath);
|
|
171
|
+
}
|
|
172
|
+
draw(false);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (k === "backspace") { if (pos > 0) { buf = buf.slice(0, pos - 1) + buf.slice(pos); pos--; } }
|
|
177
|
+
else if (k === "delete") { buf = buf.slice(0, pos) + buf.slice(pos + 1); }
|
|
178
|
+
else if (k === "left") { if (pos > 0) pos--; }
|
|
179
|
+
else if (k === "right") { if (pos < buf.length) pos++; }
|
|
180
|
+
else if (k === "home" || (key.ctrl && k === "a")) pos = 0;
|
|
181
|
+
else if (k === "end" || (key.ctrl && k === "e")) pos = buf.length;
|
|
182
|
+
else if (key.ctrl && k === "u") { buf = ""; pos = 0; }
|
|
183
|
+
else if (k === "up") {
|
|
184
|
+
if (histIdx === hist.length) draft = buf;
|
|
185
|
+
if (histIdx > 0) { histIdx--; buf = hist[histIdx] ?? ""; pos = buf.length; }
|
|
186
|
+
}
|
|
187
|
+
else if (k === "down") {
|
|
188
|
+
if (histIdx < hist.length) {
|
|
189
|
+
histIdx++;
|
|
190
|
+
buf = histIdx === hist.length ? draft : (hist[histIdx] ?? "");
|
|
191
|
+
pos = buf.length;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else if (str && !key.ctrl && !key.meta && str >= " " && str !== "\x7f") {
|
|
195
|
+
buf = buf.slice(0, pos) + str + buf.slice(pos);
|
|
196
|
+
pos += str.length;
|
|
197
|
+
}
|
|
198
|
+
else return; // ignore unhandled keys without a redraw
|
|
199
|
+
draw(false);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
process.stdout.write(HIDE);
|
|
203
|
+
readline.emitKeypressEvents(process.stdin);
|
|
204
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
205
|
+
process.stdin.resume();
|
|
206
|
+
process.stdin.on("keypress", onKey);
|
|
207
|
+
draw(true);
|
|
208
|
+
});
|
|
209
|
+
}
|