aether-code 0.36.6 → 0.36.8
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 +0 -2
- package/bin/aether-code.js +516 -516
- package/package.json +75 -77
- 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 +24 -159
- package/src/api.js +5 -2
- package/src/box-input.js +36 -133
- 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 -259
- package/src/menu.js +83 -83
- package/src/render.js +69 -136
- package/src/repl.js +93 -200
- package/src/setup.js +141 -139
- package/src/tools.js +911 -911
- package/src/update-check.js +7 -64
- package/scripts/postinstall.js +0 -182
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,6 +26,8 @@ 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.
|
|
29
31
|
export function boxLines(lines, { color = c.gray, padX = 2, minInner = 0 } = {}) {
|
|
30
32
|
const inner = Math.max(minInner, 0, ...lines.map((l) => stripAnsi(l).length));
|
|
31
33
|
const w = inner + padX * 2;
|
|
@@ -41,6 +43,8 @@ export function box(lines, opts) {
|
|
|
41
43
|
return boxLines(lines, opts).join("\n");
|
|
42
44
|
}
|
|
43
45
|
|
|
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.
|
|
44
48
|
export function sideBySide(a, b, gap = 3) {
|
|
45
49
|
const wa = Math.max(0, ...a.map((l) => stripAnsi(l).length));
|
|
46
50
|
const h = Math.max(a.length, b.length);
|
|
@@ -58,6 +62,7 @@ export function turn(n) {
|
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
export function toolHeader(name, args) {
|
|
65
|
+
// Format args compactly. If any value is huge, truncate it.
|
|
61
66
|
const compact = JSON.stringify(args);
|
|
62
67
|
const trimmed = compact.length > 120 ? compact.slice(0, 117) + "..." : compact;
|
|
63
68
|
return `${c.cyan(c.bold(name))}${c.gray("(")}${c.gray(trimmed)}${c.gray(")")}`;
|
|
@@ -65,10 +70,12 @@ export function toolHeader(name, args) {
|
|
|
65
70
|
|
|
66
71
|
const ellip = (s, n) => (s && s.length > n ? s.slice(0, n - 1) + "…" : s || "");
|
|
67
72
|
|
|
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).
|
|
68
75
|
export function toolLabel(name, args) {
|
|
69
76
|
const a = args || {};
|
|
70
77
|
const verb = (v) => c.cyan(c.bold(v));
|
|
71
|
-
const arg = (s) => c.gray(ellip(
|
|
78
|
+
const arg = (s) => c.gray(ellip(String(s), 72));
|
|
72
79
|
switch (name) {
|
|
73
80
|
case "read_file": return `${verb("read")} ${arg(a.path)}`;
|
|
74
81
|
case "write_file": return `${verb("write")} ${arg(a.path)}`;
|
|
@@ -77,174 +84,68 @@ export function toolLabel(name, args) {
|
|
|
77
84
|
case "glob_files": return `${verb("find")} ${arg(a.pattern)}`;
|
|
78
85
|
case "search_files": return `${verb("search")} ${arg(`/${a.pattern}/ in ${a.path ?? "."}`)}`;
|
|
79
86
|
case "run_shell": return `${verb("run")} ${arg(a.command)}`;
|
|
80
|
-
case "web_search": return `${verb("search web")} ${arg(a.query ?? "")}`;
|
|
87
|
+
case "web_search": return `${verb("search web")} ${arg(JSON.stringify(a.query ?? ""))}`;
|
|
81
88
|
case "web_fetch": return `${verb("fetch")} ${arg(a.url)}`;
|
|
82
|
-
case "todo_write": return "";
|
|
83
|
-
case "ask_user": return "";
|
|
89
|
+
case "todo_write": return ""; // its Plan box is the label
|
|
90
|
+
case "ask_user": return ""; // the interactive menu is its display
|
|
84
91
|
default: return `${verb(name)} ${arg(JSON.stringify(a))}`;
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
|
|
95
|
+
// Set the terminal window/tab title (so cmd.exe shows "Aether", not the node
|
|
96
|
+
// path). OSC 0 — supported by cmd.exe, Windows Terminal, and POSIX terminals.
|
|
88
97
|
export function setTerminalTitle(title) {
|
|
89
98
|
if (process.stdout.isTTY) process.stdout.write(`\x1b]0;${title}\x07`);
|
|
90
99
|
}
|
|
91
100
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
101
|
+
// Live "working…" spinner. Rewrites a single line in place (\r) with a rotating
|
|
102
|
+
// half-circle (Geometric Shapes — same block as ●, so cmd.exe-safe) + elapsed
|
|
103
|
+
// seconds, so the user sees the agent is alive while the model thinks or a slow
|
|
104
|
+
// tool runs. stop() clears the line and restores the cursor. No-op in non-TTY.
|
|
132
105
|
export function startSpinner(label) {
|
|
133
|
-
if (!process.stdout.isTTY) return { stop: () => {}
|
|
134
|
-
|
|
135
|
-
const frames = ["\xB7", "*", "+", "*", "\xB7", ".", "\xB7", "*", "+", "*", "\xB7", "."];
|
|
106
|
+
if (!process.stdout.isTTY) return { stop: () => {} };
|
|
107
|
+
const frames = ["◐", "◓", "◑", "◒"];
|
|
136
108
|
const t0 = Date.now();
|
|
137
109
|
let i = 0;
|
|
138
|
-
|
|
139
|
-
let statusText = "";
|
|
140
|
-
const isThinking = label === "thinking";
|
|
141
|
-
const thinkWord = isThinking ? pickThinkingWord() : null;
|
|
142
|
-
process.stdout.write("\x1b[?25l");
|
|
110
|
+
process.stdout.write("\x1b[?25l"); // hide cursor
|
|
143
111
|
const render = () => {
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
|
|
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}`);
|
|
112
|
+
const s = Math.floor((Date.now() - t0) / 1000);
|
|
113
|
+
const tail = s > 0 ? c.gray(` ${s}s`) : "";
|
|
114
|
+
process.stdout.write(`\r\x1b[K${c.cyan(frames[i % frames.length])} ${c.gray(label + "…")}${tail}`);
|
|
156
115
|
i++;
|
|
157
116
|
};
|
|
158
117
|
render();
|
|
159
118
|
const timer = setInterval(render, 120);
|
|
160
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
119
|
+
if (typeof timer.unref === "function") timer.unref(); // don't keep the process alive
|
|
161
120
|
return {
|
|
162
|
-
|
|
163
|
-
if (data.tokens != null) tokenCount = data.tokens;
|
|
164
|
-
if (data.status != null) statusText = data.status;
|
|
165
|
-
},
|
|
166
|
-
stop() {
|
|
121
|
+
stop: () => {
|
|
167
122
|
clearInterval(timer);
|
|
168
|
-
process.stdout.write("\r\x1b[K\x1b[?25h");
|
|
123
|
+
process.stdout.write("\r\x1b[K\x1b[?25h"); // clear line + show cursor
|
|
169
124
|
},
|
|
170
125
|
};
|
|
171
126
|
}
|
|
172
127
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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(` Brewed for ${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
|
-
|
|
128
|
+
// Terse one-line result summary instead of dumping raw JSON / file contents.
|
|
129
|
+
// Tools whose handlers already render rich output (diffs, the shell stream, the
|
|
130
|
+
// plan) just get a check — the detail was already printed.
|
|
213
131
|
export function toolSummary(name, result) {
|
|
214
132
|
const ok = result.ok;
|
|
133
|
+
// A tree branch links the result to the action above it (modern CLI style).
|
|
215
134
|
const branch = ok ? c.green("└─") : c.red("└─");
|
|
216
135
|
const out = result.output ?? "";
|
|
217
136
|
const firstLine = out.split("\n").find((l) => l.trim()) ?? "";
|
|
218
137
|
|
|
219
138
|
if (name === "run_shell") {
|
|
220
139
|
let code = null;
|
|
221
|
-
|
|
222
|
-
|
|
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)}`;
|
|
230
|
-
}
|
|
231
|
-
if (name === "todo_write") return "";
|
|
232
|
-
if (name === "ask_user") return "";
|
|
233
|
-
if (name === "write_file") {
|
|
234
|
-
return ` ${branch} ${c.gray("wrote file")}`;
|
|
140
|
+
try { code = JSON.parse(out).exit_code; } catch { /* ignore */ }
|
|
141
|
+
return ` ${branch} ${c.gray(code === null ? (ok ? "done" : "failed") : `exit ${code}`)}`;
|
|
235
142
|
}
|
|
236
|
-
if (name === "
|
|
143
|
+
if (name === "todo_write") return ""; // the Plan is its own feedback
|
|
144
|
+
if (name === "ask_user") return ""; // the menu already showed the answer
|
|
145
|
+
if (name === "write_file" || name === "edit_file") {
|
|
146
|
+
// Handler already printed the diff; echo its short status line.
|
|
237
147
|
return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
|
|
238
148
|
}
|
|
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`)}`;
|
|
247
|
-
}
|
|
248
149
|
let summary = "";
|
|
249
150
|
try {
|
|
250
151
|
const j = JSON.parse(out);
|
|
@@ -258,18 +159,28 @@ export function toolSummary(name, result) {
|
|
|
258
159
|
return ` ${branch} ${c.gray(summary)}`;
|
|
259
160
|
}
|
|
260
161
|
|
|
162
|
+
// Strip model "harmony"/channel control tokens (<|channel|>, <|message|>,
|
|
163
|
+
// <|tool_response|>, <channel|>, …) that occasionally leak into the text
|
|
164
|
+
// stream. Belt-and-suspenders alongside the server-side scrub.
|
|
165
|
+
// Only strips tokens containing a PIPE — the harmony/channel control tokens
|
|
166
|
+
// (<|channel|>, <|tool_response|>, <channel|>) always have one. Real code like
|
|
167
|
+
// <div>, Vec<T>, a < b has no pipe and is left untouched.
|
|
261
168
|
const MODEL_TOKEN_RE = /<\|[a-z_]*\|?>|<[a-z_]+\|>/gi;
|
|
262
169
|
|
|
263
170
|
export function stripModelTokens(text) {
|
|
264
171
|
return text.replace(MODEL_TOKEN_RE, "");
|
|
265
172
|
}
|
|
266
173
|
|
|
174
|
+
// Streaming-safe stripper: the leaked tokens (<|channel|>, <|tool_response|>, …)
|
|
175
|
+
// can be split across stream chunks ("<chann" then "el|>"), which a per-delta
|
|
176
|
+
// regex misses. This buffers any trailing "<…" that might be the start of a
|
|
177
|
+
// token and only emits it once it's confirmed not-a-token (or on flush).
|
|
267
178
|
export function makeTokenStripper() {
|
|
268
179
|
let buf = "";
|
|
269
180
|
return {
|
|
270
181
|
push(text) {
|
|
271
182
|
buf = (buf + text).replace(MODEL_TOKEN_RE, "");
|
|
272
|
-
const partial = buf.match(/<[|a-z_/]*$/i);
|
|
183
|
+
const partial = buf.match(/<[|a-z_/]*$/i); // possible token start at the tail
|
|
273
184
|
if (partial) {
|
|
274
185
|
const emit = buf.slice(0, partial.index);
|
|
275
186
|
buf = buf.slice(partial.index);
|
|
@@ -286,3 +197,25 @@ export function makeTokenStripper() {
|
|
|
286
197
|
},
|
|
287
198
|
};
|
|
288
199
|
}
|
|
200
|
+
|
|
201
|
+
export function toolResult(text, ok = true) {
|
|
202
|
+
const prefix = ok ? c.green(" ● ") : c.red(" × ");
|
|
203
|
+
// First line bold-ish, then dim continuation
|
|
204
|
+
const lines = text.split("\n");
|
|
205
|
+
const head = lines[0].slice(0, 200);
|
|
206
|
+
const rest = lines.slice(1, 6).join("\n").slice(0, 600);
|
|
207
|
+
return `${prefix}${head}${rest ? "\n" + c.dim(rest) : ""}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function assistant(text) {
|
|
211
|
+
// Indent each line for visual separation from tool calls
|
|
212
|
+
return text.split("\n").map((l) => ` ${l}`).join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function errorLine(msg) {
|
|
216
|
+
return `${c.red(c.bold("Error:"))} ${msg}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function note(msg) {
|
|
220
|
+
return c.dim(msg);
|
|
221
|
+
}
|