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/menu.js
CHANGED
|
@@ -1,83 +1,83 @@
|
|
|
1
|
-
// Interactive multiple-choice menu — arrow/number selectable, cmd.exe-safe.
|
|
2
|
-
//
|
|
3
|
-
// Deliberately NOT Ink (which ghosts in the legacy Windows console). Uses a
|
|
4
|
-
// simple manual redraw: print the list, and on each keypress move the cursor
|
|
5
|
-
// back up to the top of the menu (ESC[nA) and clear-to-end (ESC[J), then
|
|
6
|
-
// reprint. That's the same VT layer that already renders our ANSI colours, so
|
|
7
|
-
// it works in cmd.exe, Windows Terminal, and POSIX terminals alike.
|
|
8
|
-
//
|
|
9
|
-
// Returns the chosen option object ({label, description}), or null if cancelled.
|
|
10
|
-
|
|
11
|
-
import readline from "node:readline";
|
|
12
|
-
import { c } from "./render.js";
|
|
13
|
-
|
|
14
|
-
const ellip = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + "…" : s || "");
|
|
15
|
-
|
|
16
|
-
export function promptChoice({ question, options }) {
|
|
17
|
-
const opts = (options || [])
|
|
18
|
-
.map((o) => (typeof o === "string" ? { label: o } : o))
|
|
19
|
-
.filter((o) => o && o.label);
|
|
20
|
-
|
|
21
|
-
return new Promise((resolve) => {
|
|
22
|
-
// Non-interactive (piped / CI): pick the first option so automation never
|
|
23
|
-
// hangs waiting on a keypress that can't come. Print a note so it's visible.
|
|
24
|
-
if (!process.stdin.isTTY || opts.length === 0) {
|
|
25
|
-
const pick = opts[0] ?? null;
|
|
26
|
-
if (pick) process.stdout.write(c.cyan("● ") + c.bold(question) + c.gray(` → ${pick.label} (auto)\n`));
|
|
27
|
-
resolve(pick);
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
let sel = 0;
|
|
32
|
-
const total = opts.length + 3; // question line + options + blank + hint line
|
|
33
|
-
|
|
34
|
-
const draw = (first) => {
|
|
35
|
-
if (!first) process.stdout.write(`\x1b[${total}A`); // cursor up to menu top
|
|
36
|
-
process.stdout.write("\x1b[J"); // clear from cursor to end of screen
|
|
37
|
-
process.stdout.write(c.cyan("● ") + c.bold(question) + "\n");
|
|
38
|
-
opts.forEach((o, i) => {
|
|
39
|
-
const active = i === sel;
|
|
40
|
-
const pointer = active ? c.cyan("→") : " ";
|
|
41
|
-
const num = active ? c.cyan(c.bold(`${i + 1}.`)) : c.gray(`${i + 1}.`);
|
|
42
|
-
const label = active ? c.bold(o.label) : o.label;
|
|
43
|
-
const desc = o.description ? c.gray(" " + ellip(o.description, 60)) : "";
|
|
44
|
-
process.stdout.write(` ${pointer} ${num} ${label}${desc}\n`);
|
|
45
|
-
});
|
|
46
|
-
process.stdout.write("\n" + c.gray(" ↑/↓ move · 1-9 pick · Enter select · Esc cancel") + "\n");
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const cleanup = () => {
|
|
50
|
-
process.stdin.removeListener("keypress", onKey);
|
|
51
|
-
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
52
|
-
process.stdin.pause();
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const finish = (result) => {
|
|
56
|
-
cleanup();
|
|
57
|
-
// Collapse the menu to a compact answer line so the transcript stays clean.
|
|
58
|
-
process.stdout.write(`\x1b[${total}A\x1b[J`);
|
|
59
|
-
process.stdout.write(c.cyan("● ") + c.bold(question) + "\n");
|
|
60
|
-
if (result) process.stdout.write(" " + c.cyan("→") + " " + c.green(result.label) + "\n\n");
|
|
61
|
-
else process.stdout.write(" " + c.gray("(cancelled — proceeding with best judgment)") + "\n\n");
|
|
62
|
-
resolve(result);
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const onKey = (str, key) => {
|
|
66
|
-
if (!key) return;
|
|
67
|
-
if (key.name === "up" || key.name === "k") { sel = (sel - 1 + opts.length) % opts.length; draw(false); }
|
|
68
|
-
else if (key.name === "down" || key.name === "j" || key.name === "tab") { sel = (sel + 1) % opts.length; draw(false); }
|
|
69
|
-
else if (str && /^[1-9]$/.test(str)) {
|
|
70
|
-
const i = parseInt(str, 10) - 1;
|
|
71
|
-
if (i < opts.length) finish(opts[i]);
|
|
72
|
-
}
|
|
73
|
-
else if (key.name === "return") finish(opts[sel]);
|
|
74
|
-
else if (key.name === "escape" || (key.ctrl && key.name === "c")) finish(null);
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
readline.emitKeypressEvents(process.stdin);
|
|
78
|
-
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
79
|
-
process.stdin.resume();
|
|
80
|
-
process.stdin.on("keypress", onKey);
|
|
81
|
-
draw(true);
|
|
82
|
-
});
|
|
83
|
-
}
|
|
1
|
+
// Interactive multiple-choice menu — arrow/number selectable, cmd.exe-safe.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately NOT Ink (which ghosts in the legacy Windows console). Uses a
|
|
4
|
+
// simple manual redraw: print the list, and on each keypress move the cursor
|
|
5
|
+
// back up to the top of the menu (ESC[nA) and clear-to-end (ESC[J), then
|
|
6
|
+
// reprint. That's the same VT layer that already renders our ANSI colours, so
|
|
7
|
+
// it works in cmd.exe, Windows Terminal, and POSIX terminals alike.
|
|
8
|
+
//
|
|
9
|
+
// Returns the chosen option object ({label, description}), or null if cancelled.
|
|
10
|
+
|
|
11
|
+
import readline from "node:readline";
|
|
12
|
+
import { c } from "./render.js";
|
|
13
|
+
|
|
14
|
+
const ellip = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + "…" : s || "");
|
|
15
|
+
|
|
16
|
+
export function promptChoice({ question, options }) {
|
|
17
|
+
const opts = (options || [])
|
|
18
|
+
.map((o) => (typeof o === "string" ? { label: o } : o))
|
|
19
|
+
.filter((o) => o && o.label);
|
|
20
|
+
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
// Non-interactive (piped / CI): pick the first option so automation never
|
|
23
|
+
// hangs waiting on a keypress that can't come. Print a note so it's visible.
|
|
24
|
+
if (!process.stdin.isTTY || opts.length === 0) {
|
|
25
|
+
const pick = opts[0] ?? null;
|
|
26
|
+
if (pick) process.stdout.write(c.cyan("● ") + c.bold(question) + c.gray(` → ${pick.label} (auto)\n`));
|
|
27
|
+
resolve(pick);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let sel = 0;
|
|
32
|
+
const total = opts.length + 3; // question line + options + blank + hint line
|
|
33
|
+
|
|
34
|
+
const draw = (first) => {
|
|
35
|
+
if (!first) process.stdout.write(`\x1b[${total}A`); // cursor up to menu top
|
|
36
|
+
process.stdout.write("\x1b[J"); // clear from cursor to end of screen
|
|
37
|
+
process.stdout.write(c.cyan("● ") + c.bold(question) + "\n");
|
|
38
|
+
opts.forEach((o, i) => {
|
|
39
|
+
const active = i === sel;
|
|
40
|
+
const pointer = active ? c.cyan("→") : " ";
|
|
41
|
+
const num = active ? c.cyan(c.bold(`${i + 1}.`)) : c.gray(`${i + 1}.`);
|
|
42
|
+
const label = active ? c.bold(o.label) : o.label;
|
|
43
|
+
const desc = o.description ? c.gray(" " + ellip(o.description, 60)) : "";
|
|
44
|
+
process.stdout.write(` ${pointer} ${num} ${label}${desc}\n`);
|
|
45
|
+
});
|
|
46
|
+
process.stdout.write("\n" + c.gray(" ↑/↓ move · 1-9 pick · Enter select · Esc cancel") + "\n");
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const cleanup = () => {
|
|
50
|
+
process.stdin.removeListener("keypress", onKey);
|
|
51
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
52
|
+
process.stdin.pause();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const finish = (result) => {
|
|
56
|
+
cleanup();
|
|
57
|
+
// Collapse the menu to a compact answer line so the transcript stays clean.
|
|
58
|
+
process.stdout.write(`\x1b[${total}A\x1b[J`);
|
|
59
|
+
process.stdout.write(c.cyan("● ") + c.bold(question) + "\n");
|
|
60
|
+
if (result) process.stdout.write(" " + c.cyan("→") + " " + c.green(result.label) + "\n\n");
|
|
61
|
+
else process.stdout.write(" " + c.gray("(cancelled — proceeding with best judgment)") + "\n\n");
|
|
62
|
+
resolve(result);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const onKey = (str, key) => {
|
|
66
|
+
if (!key) return;
|
|
67
|
+
if (key.name === "up" || key.name === "k") { sel = (sel - 1 + opts.length) % opts.length; draw(false); }
|
|
68
|
+
else if (key.name === "down" || key.name === "j" || key.name === "tab") { sel = (sel + 1) % opts.length; draw(false); }
|
|
69
|
+
else if (str && /^[1-9]$/.test(str)) {
|
|
70
|
+
const i = parseInt(str, 10) - 1;
|
|
71
|
+
if (i < opts.length) finish(opts[i]);
|
|
72
|
+
}
|
|
73
|
+
else if (key.name === "return") finish(opts[sel]);
|
|
74
|
+
else if (key.name === "escape" || (key.ctrl && key.name === "c")) finish(null);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
readline.emitKeypressEvents(process.stdin);
|
|
78
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
79
|
+
process.stdin.resume();
|
|
80
|
+
process.stdin.on("keypress", onKey);
|
|
81
|
+
draw(true);
|
|
82
|
+
});
|
|
83
|
+
}
|
package/src/render.js
CHANGED
|
@@ -26,8 +26,6 @@ export function divider() {
|
|
|
26
26
|
|
|
27
27
|
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
28
28
|
|
|
29
|
-
// Draw a bordered box around content lines, returning the rows as an array (so
|
|
30
|
-
// boxes can be composed side by side). Rounded box-drawing chars.
|
|
31
29
|
export function boxLines(lines, { color = c.gray, padX = 2, minInner = 0 } = {}) {
|
|
32
30
|
const inner = Math.max(minInner, 0, ...lines.map((l) => stripAnsi(l).length));
|
|
33
31
|
const w = inner + padX * 2;
|
|
@@ -43,8 +41,6 @@ export function box(lines, opts) {
|
|
|
43
41
|
return boxLines(lines, opts).join("\n");
|
|
44
42
|
}
|
|
45
43
|
|
|
46
|
-
// Join two blocks of lines horizontally with a gap, padding the left block to a
|
|
47
|
-
// uniform width so the right block aligns. Shorter block is padded with blanks.
|
|
48
44
|
export function sideBySide(a, b, gap = 3) {
|
|
49
45
|
const wa = Math.max(0, ...a.map((l) => stripAnsi(l).length));
|
|
50
46
|
const h = Math.max(a.length, b.length);
|
|
@@ -62,7 +58,6 @@ export function turn(n) {
|
|
|
62
58
|
}
|
|
63
59
|
|
|
64
60
|
export function toolHeader(name, args) {
|
|
65
|
-
// Format args compactly. If any value is huge, truncate it.
|
|
66
61
|
const compact = JSON.stringify(args);
|
|
67
62
|
const trimmed = compact.length > 120 ? compact.slice(0, 117) + "..." : compact;
|
|
68
63
|
return `${c.cyan(c.bold(name))}${c.gray("(")}${c.gray(trimmed)}${c.gray(")")}`;
|
|
@@ -70,114 +65,185 @@ export function toolHeader(name, args) {
|
|
|
70
65
|
|
|
71
66
|
const ellip = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + "…" : s || "");
|
|
72
67
|
|
|
73
|
-
// Clean one-line label for a tool call — a verb + its key argument, instead of
|
|
74
|
-
// dumping raw JSON (which buried file contents / queries in noise).
|
|
75
68
|
export function toolLabel(name, args) {
|
|
76
69
|
const a = args || {};
|
|
77
70
|
const verb = (v) => c.cyan(c.bold(v));
|
|
78
|
-
const arg = (s) => c.gray(ellip(String(s), 72));
|
|
71
|
+
const arg = (s) => c.gray(ellip(s == null ? "" : String(s), 72));
|
|
79
72
|
switch (name) {
|
|
80
|
-
case "read_file": return `${verb("
|
|
81
|
-
case "write_file": return `${verb("
|
|
82
|
-
case "edit_file": return `${verb("
|
|
83
|
-
case "list_dir": return `${verb("
|
|
84
|
-
case "glob_files": return `${verb("
|
|
85
|
-
case "search_files": return `${verb("
|
|
86
|
-
case "run_shell": return `${verb("
|
|
87
|
-
case "web_search": return `${verb("
|
|
88
|
-
case "web_fetch": return `${verb("
|
|
89
|
-
case "todo_write": return "";
|
|
90
|
-
case "ask_user": return "";
|
|
91
|
-
case "git_status": return `${verb("git")} ${arg("status")}`;
|
|
92
|
-
case "git_diff": return `${verb("git")} ${arg("diff" + (a.staged ? " --staged" : "") + (a.base ? ` ${a.base}...${a.head || "HEAD"}` : ""))}`;
|
|
93
|
-
case "git_log": return `${verb("git")} ${arg("log" + (a.file ? ` -- ${a.file}` : "") + (a.branch ? ` ${a.branch}` : ""))}`;
|
|
94
|
-
case "git_commit": return `${verb("git")} ${arg(`commit "${ellip(a.message || "", 50)}"`)}`
|
|
95
|
-
case "git_branch": return `${verb("git")} ${arg(`branch ${a.name}`)}`;
|
|
96
|
-
case "git_push": return `${verb("git")} ${arg("push" + (a.force ? " --force" : ""))}`;
|
|
97
|
-
case "git_create_pr": return `${verb("git")} ${arg(`pr "${ellip(a.title || "", 50)}"`)}`
|
|
73
|
+
case "read_file": return `${verb("Read")} ${arg(a.path)}`;
|
|
74
|
+
case "write_file": return `${verb("Write")} ${arg(a.path)}`;
|
|
75
|
+
case "edit_file": return `${verb("Edit")} ${arg(a.path)}`;
|
|
76
|
+
case "list_dir": return `${verb("List")} ${arg(a.path)}`;
|
|
77
|
+
case "glob_files": return `${verb("Glob")} ${arg(a.pattern)}`;
|
|
78
|
+
case "search_files": return `${verb("Grep")} ${arg(`/${a.pattern}/ in ${a.path ?? "."}`)}`;
|
|
79
|
+
case "run_shell": return `${verb("Bash")} ${arg(a.command)}`;
|
|
80
|
+
case "web_search": return `${verb("WebSearch")} ${arg(a.query ?? "")}`;
|
|
81
|
+
case "web_fetch": return `${verb("WebFetch")} ${arg(a.url)}`;
|
|
82
|
+
case "todo_write": return "";
|
|
83
|
+
case "ask_user": return "";
|
|
98
84
|
default: return `${verb(name)} ${arg(JSON.stringify(a))}`;
|
|
99
85
|
}
|
|
100
86
|
}
|
|
101
87
|
|
|
102
|
-
// Set the terminal window/tab title (so cmd.exe shows "Aether", not the node
|
|
103
|
-
// path). OSC 0 — supported by cmd.exe, Windows Terminal, and POSIX terminals.
|
|
104
88
|
export function setTerminalTitle(title) {
|
|
105
89
|
if (process.stdout.isTTY) process.stdout.write(`\x1b]0;${title}\x07`);
|
|
106
90
|
}
|
|
107
91
|
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
92
|
+
// Fun thinking words — like Claude Code's "Hullaballooing...", "Cogitating..."
|
|
93
|
+
const THINKING_WORDS = [
|
|
94
|
+
"Thinking",
|
|
95
|
+
"Pondering",
|
|
96
|
+
"Mulling",
|
|
97
|
+
"Cogitating",
|
|
98
|
+
"Ruminating",
|
|
99
|
+
"Deliberating",
|
|
100
|
+
"Contemplating",
|
|
101
|
+
"Noodling",
|
|
102
|
+
"Musing",
|
|
103
|
+
"Brainstorming",
|
|
104
|
+
"Percolating",
|
|
105
|
+
"Brainwaving",
|
|
106
|
+
"Daydreaming",
|
|
107
|
+
"Hullaballooing",
|
|
108
|
+
"Churning",
|
|
109
|
+
"Brewing",
|
|
110
|
+
"Conjuring",
|
|
111
|
+
"Scheming",
|
|
112
|
+
"Hatching",
|
|
113
|
+
"Concocting",
|
|
114
|
+
"Simmering",
|
|
115
|
+
"Marinating",
|
|
116
|
+
"Fermenting",
|
|
117
|
+
"Stewing",
|
|
118
|
+
"Whittling",
|
|
119
|
+
"Tinkering",
|
|
120
|
+
"Puzzling",
|
|
121
|
+
"Deciphering",
|
|
122
|
+
"Unraveling",
|
|
123
|
+
"Distilling",
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// Pick a random thinking word (seeded by current second so it varies per call)
|
|
127
|
+
function pickThinkingWord() {
|
|
128
|
+
const idx = (Date.now() % (THINKING_WORDS.length * 1000)) / 1000 | 0;
|
|
129
|
+
return THINKING_WORDS[idx % THINKING_WORDS.length];
|
|
130
|
+
}
|
|
131
|
+
|
|
112
132
|
export function startSpinner(label) {
|
|
113
|
-
if (!process.stdout.isTTY) return { stop: () => {} };
|
|
114
|
-
|
|
133
|
+
if (!process.stdout.isTTY) return { stop: () => {}, update: () => {} };
|
|
134
|
+
// Ping-pong animation — cmd.exe-safe chars inspired by Claude Code's sparkle cycle
|
|
135
|
+
const frames = ["\xB7", "*", "+", "*", "\xB7", ".", "\xB7", "*", "+", "*", "\xB7", "."];
|
|
115
136
|
const t0 = Date.now();
|
|
116
137
|
let i = 0;
|
|
117
|
-
|
|
138
|
+
let tokenCount = 0;
|
|
139
|
+
let statusText = "";
|
|
140
|
+
const isThinking = label === "thinking";
|
|
141
|
+
const thinkWord = isThinking ? pickThinkingWord() : null;
|
|
142
|
+
process.stdout.write("\x1b[?25l");
|
|
118
143
|
const render = () => {
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
144
|
+
const ms = Date.now() - t0;
|
|
145
|
+
const secs = Math.floor(ms / 1000);
|
|
146
|
+
const timeFmt = secs >= 60
|
|
147
|
+
? `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, "0")}s`
|
|
148
|
+
: `${secs}s`;
|
|
149
|
+
const parts = [];
|
|
150
|
+
if (secs > 0) parts.push(timeFmt);
|
|
151
|
+
if (tokenCount > 0) parts.push(`${fmtTokens(tokenCount)} tokens`);
|
|
152
|
+
if (statusText) parts.push(statusText);
|
|
153
|
+
const metrics = parts.length > 0 ? c.gray(` (${parts.join(" \xB7 ")})`) : "";
|
|
154
|
+
const displayLabel = isThinking ? thinkWord : label;
|
|
155
|
+
process.stdout.write(`\r\x1b[K${c.cyan(frames[i % frames.length])} ${c.dim(displayLabel + "...")}${metrics}`);
|
|
122
156
|
i++;
|
|
123
157
|
};
|
|
124
158
|
render();
|
|
125
159
|
const timer = setInterval(render, 120);
|
|
126
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
160
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
127
161
|
return {
|
|
128
|
-
|
|
162
|
+
update(data) {
|
|
163
|
+
if (data.tokens != null) tokenCount = data.tokens;
|
|
164
|
+
if (data.status != null) statusText = data.status;
|
|
165
|
+
},
|
|
166
|
+
stop() {
|
|
129
167
|
clearInterval(timer);
|
|
130
|
-
process.stdout.write("\r\x1b[K\x1b[?25h");
|
|
168
|
+
process.stdout.write("\r\x1b[K\x1b[?25h");
|
|
131
169
|
},
|
|
132
170
|
};
|
|
133
171
|
}
|
|
134
172
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
173
|
+
export function fmtTokens(n) {
|
|
174
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
|
175
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
|
|
176
|
+
return String(n);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function turnStats({ elapsed, tokensIn, tokensOut, tools, credits }) {
|
|
180
|
+
const parts = [];
|
|
181
|
+
if (elapsed > 0) {
|
|
182
|
+
const s = Math.floor(elapsed / 1000);
|
|
183
|
+
parts.push(s >= 60 ? `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s` : `${s}s`);
|
|
184
|
+
}
|
|
185
|
+
if (tokensIn > 0 || tokensOut > 0) {
|
|
186
|
+
parts.push(`${fmtTokens(tokensIn)}->${fmtTokens(tokensOut)} tokens`);
|
|
187
|
+
}
|
|
188
|
+
if (tools > 0) parts.push(`${tools} tool call${tools === 1 ? "" : "s"}`);
|
|
189
|
+
if (credits > 0) parts.push(`${credits} credit${credits === 1 ? "" : "s"}`);
|
|
190
|
+
return parts.length > 0 ? c.dim(` ${parts.join(" \xB7 ")}`) : "";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function toolResult(text, ok = true) {
|
|
194
|
+
const prefix = ok ? c.green(" ● ") : c.red(" × ");
|
|
195
|
+
const lines = text.split("\n");
|
|
196
|
+
const head = lines[0].slice(0, 200);
|
|
197
|
+
const rest = lines.slice(1, 6).join("\n").slice(0, 600);
|
|
198
|
+
return `${prefix}${head}${rest ? "\n" + c.dim(rest) : ""}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function assistant(text) {
|
|
202
|
+
return text.split("\n").map((l) => ` ${l}`).join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function errorLine(msg) {
|
|
206
|
+
return `${c.red(c.bold("Error:"))} ${msg}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function note(msg) {
|
|
210
|
+
return c.dim(msg);
|
|
211
|
+
}
|
|
212
|
+
|
|
138
213
|
export function toolSummary(name, result) {
|
|
139
214
|
const ok = result.ok;
|
|
140
|
-
// A tree branch links the result to the action above it (modern CLI style).
|
|
141
215
|
const branch = ok ? c.green("└─") : c.red("└─");
|
|
142
216
|
const out = result.output ?? "";
|
|
143
217
|
const firstLine = out.split("\n").find((l) => l.trim()) ?? "";
|
|
144
218
|
|
|
145
219
|
if (name === "run_shell") {
|
|
146
220
|
let code = null;
|
|
147
|
-
|
|
148
|
-
return ` ${branch} ${c.gray(code === null ? (ok ? "done" : "failed") : `exit ${code}`)}`;
|
|
149
|
-
}
|
|
150
|
-
if (name === "todo_write") return ""; // the Plan is its own feedback
|
|
151
|
-
if (name === "ask_user") return ""; // the menu already showed the answer
|
|
152
|
-
if (name === "git_commit" || name === "git_push" || name === "git_create_pr") {
|
|
153
|
-
return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
|
|
154
|
-
}
|
|
155
|
-
if (name === "git_status") {
|
|
221
|
+
let stderr = "";
|
|
156
222
|
try {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
} catch { /* fall through */ }
|
|
165
|
-
}
|
|
166
|
-
if (name === "git_diff") {
|
|
167
|
-
if (out === "(no changes)") return ` ${branch} ${c.gray("no changes")}`;
|
|
168
|
-
const diffLines = out.split("\n").length;
|
|
169
|
-
return ` ${branch} ${c.gray(`${diffLines} lines of diff`)}`;
|
|
223
|
+
const j = JSON.parse(out);
|
|
224
|
+
code = j.exit_code;
|
|
225
|
+
stderr = (j.stderr || "").split("\n")[0] || "";
|
|
226
|
+
} catch { /* ignore */ }
|
|
227
|
+
const status = code === null ? (ok ? "done" : "failed") : code === 0 ? "done" : `exit ${code}`;
|
|
228
|
+
const detail = !ok && stderr ? ` ${ellip(stderr, 60)}` : "";
|
|
229
|
+
return ` ${branch} ${c.gray(status + detail)}`;
|
|
170
230
|
}
|
|
171
|
-
if (name === "
|
|
172
|
-
|
|
173
|
-
|
|
231
|
+
if (name === "todo_write") return "";
|
|
232
|
+
if (name === "ask_user") return "";
|
|
233
|
+
if (name === "write_file") {
|
|
234
|
+
return ` ${branch} ${c.gray("wrote file")}`;
|
|
174
235
|
}
|
|
175
|
-
if (name === "
|
|
236
|
+
if (name === "edit_file") {
|
|
176
237
|
return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
|
|
177
238
|
}
|
|
178
|
-
if (name === "
|
|
179
|
-
|
|
180
|
-
|
|
239
|
+
if (name === "web_search") {
|
|
240
|
+
let count = 0;
|
|
241
|
+
try { const j = JSON.parse(out); if (Array.isArray(j)) count = j.length; } catch { /* ignore */ }
|
|
242
|
+
return ` ${branch} ${c.gray(count > 0 ? `${count} results` : "no results")}`;
|
|
243
|
+
}
|
|
244
|
+
if (name === "web_fetch") {
|
|
245
|
+
const lines = out.split("\n").length;
|
|
246
|
+
return ` ${branch} ${c.gray(`${lines} lines fetched`)}`;
|
|
181
247
|
}
|
|
182
248
|
let summary = "";
|
|
183
249
|
try {
|
|
@@ -192,28 +258,18 @@ export function toolSummary(name, result) {
|
|
|
192
258
|
return ` ${branch} ${c.gray(summary)}`;
|
|
193
259
|
}
|
|
194
260
|
|
|
195
|
-
// Strip model "harmony"/channel control tokens (<|channel|>, <|message|>,
|
|
196
|
-
// <|tool_response|>, <channel|>, …) that occasionally leak into the text
|
|
197
|
-
// stream. Belt-and-suspenders alongside the server-side scrub.
|
|
198
|
-
// Only strips tokens containing a PIPE — the harmony/channel control tokens
|
|
199
|
-
// (<|channel|>, <|tool_response|>, <channel|>) always have one. Real code like
|
|
200
|
-
// <div>, Vec<T>, a < b has no pipe and is left untouched.
|
|
201
261
|
const MODEL_TOKEN_RE = /<\|[a-z_]*\|?>|<[a-z_]+\|>/gi;
|
|
202
262
|
|
|
203
263
|
export function stripModelTokens(text) {
|
|
204
264
|
return text.replace(MODEL_TOKEN_RE, "");
|
|
205
265
|
}
|
|
206
266
|
|
|
207
|
-
// Streaming-safe stripper: the leaked tokens (<|channel|>, <|tool_response|>, …)
|
|
208
|
-
// can be split across stream chunks ("<chann" then "el|>"), which a per-delta
|
|
209
|
-
// regex misses. This buffers any trailing "<…" that might be the start of a
|
|
210
|
-
// token and only emits it once it's confirmed not-a-token (or on flush).
|
|
211
267
|
export function makeTokenStripper() {
|
|
212
268
|
let buf = "";
|
|
213
269
|
return {
|
|
214
270
|
push(text) {
|
|
215
271
|
buf = (buf + text).replace(MODEL_TOKEN_RE, "");
|
|
216
|
-
const partial = buf.match(/<[|a-z_/]*$/i);
|
|
272
|
+
const partial = buf.match(/<[|a-z_/]*$/i);
|
|
217
273
|
if (partial) {
|
|
218
274
|
const emit = buf.slice(0, partial.index);
|
|
219
275
|
buf = buf.slice(partial.index);
|
|
@@ -230,25 +286,3 @@ export function makeTokenStripper() {
|
|
|
230
286
|
},
|
|
231
287
|
};
|
|
232
288
|
}
|
|
233
|
-
|
|
234
|
-
export function toolResult(text, ok = true) {
|
|
235
|
-
const prefix = ok ? c.green(" ● ") : c.red(" × ");
|
|
236
|
-
// First line bold-ish, then dim continuation
|
|
237
|
-
const lines = text.split("\n");
|
|
238
|
-
const head = lines[0].slice(0, 200);
|
|
239
|
-
const rest = lines.slice(1, 6).join("\n").slice(0, 600);
|
|
240
|
-
return `${prefix}${head}${rest ? "\n" + c.dim(rest) : ""}`;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
export function assistant(text) {
|
|
244
|
-
// Indent each line for visual separation from tool calls
|
|
245
|
-
return text.split("\n").map((l) => ` ${l}`).join("\n");
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
export function errorLine(msg) {
|
|
249
|
-
return `${c.red(c.bold("Error:"))} ${msg}`;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
export function note(msg) {
|
|
253
|
-
return c.dim(msg);
|
|
254
|
-
}
|