@jpssff/vanor 0.1.0
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/README-cn.md +166 -0
- package/README.md +120 -0
- package/base/config.js +162 -0
- package/base/core/compaction.js +58 -0
- package/base/core/harness.js +246 -0
- package/base/core/loop.js +72 -0
- package/base/core/prompt.js +126 -0
- package/base/core/session.js +255 -0
- package/base/events.js +54 -0
- package/base/i18n/index.js +80 -0
- package/base/i18n/locales/en.js +254 -0
- package/base/i18n/locales/zh-CN.js +252 -0
- package/base/llm/index.js +119 -0
- package/base/llm/providers/anthropic.js +147 -0
- package/base/llm/providers/openai.js +155 -0
- package/base/llm/sse.js +27 -0
- package/base/llm/trace.js +64 -0
- package/base/logger.js +57 -0
- package/base/memory/index.js +139 -0
- package/base/security/index.js +77 -0
- package/base/skills/loader.js +297 -0
- package/base/test/cli.test.js +91 -0
- package/base/test/config.test.js +63 -0
- package/base/test/core.test.js +154 -0
- package/base/test/i18n.test.js +32 -0
- package/base/test/loop.test.js +97 -0
- package/base/test/memory.test.js +47 -0
- package/base/test/message.test.js +38 -0
- package/base/test/session.test.js +324 -0
- package/base/test/skills.test.js +236 -0
- package/base/test/statusbar.test.js +143 -0
- package/base/test/tools.test.js +127 -0
- package/base/test/trace.test.js +62 -0
- package/base/test/tui.test.js +242 -0
- package/base/test/utils.test.js +35 -0
- package/base/tools/builtin.js +221 -0
- package/base/tools/index.js +157 -0
- package/base/transport/cli.js +417 -0
- package/base/transport/message.js +81 -0
- package/base/transport/statusbar.js +117 -0
- package/base/transport/tui.js +397 -0
- package/base/utils.js +150 -0
- package/docs/TECH_DESIGN.md +544 -0
- package/index.js +175 -0
- package/package.json +33 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// CLI 交互层:readline TUI、流式渲染、工具展示、审批、slash 命令、底部状态条。
|
|
2
|
+
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import { stdin, stdout } from "node:process";
|
|
5
|
+
import { c } from "../utils.js";
|
|
6
|
+
import { listSessions, listUserMessages } from "../core/session.js";
|
|
7
|
+
import { createI18n } from "../i18n/index.js";
|
|
8
|
+
import { summarizeSkills } from "../skills/loader.js";
|
|
9
|
+
import { StatusBar } from "./statusbar.js";
|
|
10
|
+
import { displayWidth, TerminalUI } from "./tui.js";
|
|
11
|
+
|
|
12
|
+
export function helpText(t = createI18n("en").t) {
|
|
13
|
+
return t("cli.help.text");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function preview(text, n = 240) {
|
|
17
|
+
const s = String(text || "").replace(/\s+/g, " ").trim();
|
|
18
|
+
return s.length > n ? s.slice(0, n) + "…" : s;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatMessageTime(value) {
|
|
22
|
+
const d = new Date(value);
|
|
23
|
+
if (!Number.isFinite(d.getTime())) return String(value || "-").slice(0, 19);
|
|
24
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
25
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatUserMessageRow(message, contentLimit = 160) {
|
|
29
|
+
return `${formatMessageTime(message.time)} ${preview(message.content, contentLimit)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function availableModels(config, currentModel = "") {
|
|
33
|
+
const out = [];
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
const add = (model) => {
|
|
36
|
+
const value = String(model || "").trim();
|
|
37
|
+
if (!value || seen.has(value)) return;
|
|
38
|
+
seen.add(value);
|
|
39
|
+
out.push(value);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
add(currentModel);
|
|
43
|
+
add(config.llm?.defaultModel);
|
|
44
|
+
for (const model of config.llm?.fallback || []) add(model);
|
|
45
|
+
|
|
46
|
+
for (const [providerName, provider] of Object.entries(config.llm?.providers || {})) {
|
|
47
|
+
for (const model of provider.models || []) {
|
|
48
|
+
const id = typeof model === "string" ? model : model?.id || model?.name || "";
|
|
49
|
+
if (!id) continue;
|
|
50
|
+
add(id.startsWith(`${providerName}/`) ? id : `${providerName}/${id}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function searchModels(models, query) {
|
|
57
|
+
const q = String(query || "").trim().toLowerCase();
|
|
58
|
+
if (!q) return models;
|
|
59
|
+
return models.filter((model) => model.toLowerCase().includes(q));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 把 readline 的 clearScreenDown(ESC[0J / ESC[J) 降级为“清到行尾”(ESC[K)。
|
|
64
|
+
* 退格/重绘时 readline 会擦除光标下方的所有内容,连带删除底部状态条造成闪烁;
|
|
65
|
+
* 降级后只清当前输入行的残留,不再触及下方状态行。其它清屏序列(如 ESC[2K)不受影响。
|
|
66
|
+
*/
|
|
67
|
+
export function downgradeClearScreenDown(chunk) {
|
|
68
|
+
return typeof chunk === "string" ? chunk.replace(/\x1b\[0?J/g, "\x1b[K") : chunk;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function wrapText(text, width, indent = "") {
|
|
72
|
+
const max = Math.max(10, width);
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const rawLine of String(text ?? "").split(/\r?\n/)) {
|
|
75
|
+
const words = rawLine.split(/(\s+)/).filter(Boolean);
|
|
76
|
+
let line = indent;
|
|
77
|
+
|
|
78
|
+
const pushLine = () => {
|
|
79
|
+
out.push(line.trimEnd());
|
|
80
|
+
line = indent;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
for (const word of words.length ? words : [""]) {
|
|
84
|
+
if (/^\s+$/.test(word)) {
|
|
85
|
+
if (line !== indent) line += " ";
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (displayWidth(line + word) <= max) {
|
|
90
|
+
line += word;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (line !== indent) pushLine();
|
|
95
|
+
for (const ch of Array.from(word)) {
|
|
96
|
+
if (displayWidth(line + ch) > max && line !== indent) pushLine();
|
|
97
|
+
line += ch;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
pushLine();
|
|
101
|
+
}
|
|
102
|
+
return out.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function formatConfirmMessage(message, cols = 80, t = createI18n("en").t) {
|
|
106
|
+
const width = Math.max(24, Math.min(cols || 80, 120));
|
|
107
|
+
return `${t("cli.confirmMessage")}\n${wrapText(message, width, " ")}\n`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function startCli(harness, { config }) {
|
|
111
|
+
const useTui = !!(stdin.isTTY && stdout.isTTY);
|
|
112
|
+
// 过滤 readline 的清屏序列:退格/重绘时 readline 会用 clearScreenDown(ESC[0J)
|
|
113
|
+
// 擦掉光标下方,连带删除底部状态条,造成一闪一闪。这里把它降级为“清到行尾”
|
|
114
|
+
// (ESC[K),只清当前输入行的残留,不再触及下方的状态行。
|
|
115
|
+
const rlOutput = stdout.isTTY
|
|
116
|
+
? new Proxy(stdout, {
|
|
117
|
+
get(target, prop) {
|
|
118
|
+
if (prop === "write") {
|
|
119
|
+
return (chunk, enc, cb) => target.write(downgradeClearScreenDown(chunk), enc, cb);
|
|
120
|
+
}
|
|
121
|
+
const val = target[prop];
|
|
122
|
+
return typeof val === "function" ? val.bind(target) : val;
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
: stdout;
|
|
126
|
+
const rl = useTui ? null : readline.createInterface({ input: stdin, output: rlOutput });
|
|
127
|
+
|
|
128
|
+
const statusBar = new StatusBar(() => ({
|
|
129
|
+
used: harness.session.estimatedTokens(),
|
|
130
|
+
total: harness.config.agent?.contextWindow ?? config.agent?.contextWindow ?? 256000,
|
|
131
|
+
model: harness.model,
|
|
132
|
+
cwd: harness.security.workspaceRoot,
|
|
133
|
+
approval: harness.security.approval,
|
|
134
|
+
}), { reservedInputLines: useTui ? 1 : 0 });
|
|
135
|
+
const tui = useTui ? new TerminalUI({ input: stdin, output: stdout, statusBar, prompt: c.cyan("› ") }) : null;
|
|
136
|
+
const write = (s) => (tui ? tui.write(s) : stdout.write(s));
|
|
137
|
+
const readInput = () => (tui ? tui.readLine() : rl.question(c.cyan("› ")));
|
|
138
|
+
const askInline = (prompt) => (tui ? tui.ask(prompt) : rl.question(prompt));
|
|
139
|
+
const t = (key, vars) => harness.i18n.t(key, vars);
|
|
140
|
+
|
|
141
|
+
let running = false;
|
|
142
|
+
let abort = null;
|
|
143
|
+
let lastInput = "";
|
|
144
|
+
|
|
145
|
+
const cleanup = () => {
|
|
146
|
+
harness.saveState?.();
|
|
147
|
+
tui?.disable();
|
|
148
|
+
statusBar.disable();
|
|
149
|
+
};
|
|
150
|
+
process.on("exit", cleanup);
|
|
151
|
+
|
|
152
|
+
statusBar.enable();
|
|
153
|
+
tui?.enable();
|
|
154
|
+
write(c.bold(c.cyan(t("cli.banner"))) + "\n");
|
|
155
|
+
write(c.gray(`${t("cli.intro")}\n\n`));
|
|
156
|
+
if (harness.sessionRestored) {
|
|
157
|
+
write(c.gray(`${t("cli.restored", { id: harness.session.id, count: harness.session.messages.length })}\n\n`));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const onSigint = () => {
|
|
161
|
+
if (running && abort) {
|
|
162
|
+
abort.abort();
|
|
163
|
+
write(c.yellow(`\n${t("cli.interruptRequested")}\n`));
|
|
164
|
+
} else {
|
|
165
|
+
cleanup();
|
|
166
|
+
write(`\n${t("cli.goodbye")}\n\n`);
|
|
167
|
+
rl?.close();
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
if (tui) tui.onSigint = onSigint;
|
|
172
|
+
else rl.on("SIGINT", onSigint);
|
|
173
|
+
|
|
174
|
+
const handlers = {
|
|
175
|
+
_streaming: false,
|
|
176
|
+
onText(delta) {
|
|
177
|
+
if (!this._streaming) {
|
|
178
|
+
write(c.green("vanor ▸ "));
|
|
179
|
+
this._streaming = true;
|
|
180
|
+
}
|
|
181
|
+
write(delta);
|
|
182
|
+
},
|
|
183
|
+
onAssistant() {
|
|
184
|
+
if (this._streaming) {
|
|
185
|
+
write("\n");
|
|
186
|
+
this._streaming = false;
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
onToolCalls(calls) {
|
|
190
|
+
for (const call of calls) {
|
|
191
|
+
const arg =
|
|
192
|
+
call.name === "exec"
|
|
193
|
+
? call.arguments?.command
|
|
194
|
+
: call.arguments?.path || JSON.stringify(call.arguments || {});
|
|
195
|
+
write(c.gray(` ⚙ ${call.name} ${preview(arg, 80)}\n`));
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
onToolResult(call, msg) {
|
|
199
|
+
const mark = msg.meta?.isError ? c.red(" ✗ ") : c.gray(" ✓ ");
|
|
200
|
+
write(mark + c.gray(preview(msg.content)) + "\n");
|
|
201
|
+
},
|
|
202
|
+
onError(e) {
|
|
203
|
+
write(c.red(`\n${t("cli.error", { message: e.message })}\n`));
|
|
204
|
+
},
|
|
205
|
+
confirm: async (message) => {
|
|
206
|
+
write(c.yellow(formatConfirmMessage(message, stdout.columns || 80, t)));
|
|
207
|
+
const prompt = c.yellow(t("cli.confirmPrompt"));
|
|
208
|
+
const ans = tui ? await tui.ask(prompt) : await rl.question(prompt);
|
|
209
|
+
return /^y(es)?$/i.test(ans.trim());
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
async function chooseModel(matches, query) {
|
|
214
|
+
write(c.gray(`${t("cli.modelMatched", { count: matches.length })}\n`));
|
|
215
|
+
matches.forEach((model, index) => {
|
|
216
|
+
const mark = model === harness.model ? " *" : "";
|
|
217
|
+
write(c.gray(` ${index + 1}. ${model}${mark}\n`));
|
|
218
|
+
});
|
|
219
|
+
const ans = (await askInline(c.cyan(t("cli.chooseModel", { count: matches.length })))).trim();
|
|
220
|
+
if (!ans) return "";
|
|
221
|
+
const n = Number(ans);
|
|
222
|
+
if (!Number.isInteger(n) || n < 1 || n > matches.length) {
|
|
223
|
+
write(c.red(`${t("cli.invalidChoice")}\n`));
|
|
224
|
+
return "";
|
|
225
|
+
}
|
|
226
|
+
return matches[n - 1];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function handleSlash(input) {
|
|
230
|
+
const [cmd, ...rest] = input.slice(1).split(/\s+/);
|
|
231
|
+
const arg = rest.join(" ").trim();
|
|
232
|
+
switch (cmd) {
|
|
233
|
+
case "help":
|
|
234
|
+
write(helpText(t) + "\n");
|
|
235
|
+
return true;
|
|
236
|
+
case "exit":
|
|
237
|
+
case "quit":
|
|
238
|
+
cleanup();
|
|
239
|
+
rl?.close();
|
|
240
|
+
process.exit(0);
|
|
241
|
+
return true;
|
|
242
|
+
case "new":
|
|
243
|
+
case "reset":
|
|
244
|
+
harness.newSession();
|
|
245
|
+
write(c.gray(`${t("cli.newSession")}\n`));
|
|
246
|
+
return true;
|
|
247
|
+
case "compact":
|
|
248
|
+
write(c.gray(`${t("cli.compactRunning")}\n`));
|
|
249
|
+
statusBar.setBusy(true);
|
|
250
|
+
statusBar.render(true);
|
|
251
|
+
try {
|
|
252
|
+
await harness.compactNow();
|
|
253
|
+
write(c.gray(`${t("cli.compactDone")}\n`));
|
|
254
|
+
} finally {
|
|
255
|
+
statusBar.setBusy(false);
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
case "model":
|
|
259
|
+
if (arg) {
|
|
260
|
+
const models = availableModels(harness.config, harness.model);
|
|
261
|
+
const matches = searchModels(models, arg);
|
|
262
|
+
const exact = matches.find((model) => model.toLowerCase() === arg.toLowerCase());
|
|
263
|
+
let target = exact || (matches.length === 1 ? matches[0] : "");
|
|
264
|
+
if (!target && matches.length > 1) target = await chooseModel(matches, arg);
|
|
265
|
+
if (!target && matches.length === 0) target = arg;
|
|
266
|
+
if (!target) return true;
|
|
267
|
+
try {
|
|
268
|
+
harness.setModel(target);
|
|
269
|
+
write(c.gray(`${t("cli.modelChanged", { model: target })}\n`));
|
|
270
|
+
} catch (e) {
|
|
271
|
+
write(c.red(`${t("cli.modelSwitchFailed", { message: e.message })}\n`));
|
|
272
|
+
if (matches.length === 0 && models.length) {
|
|
273
|
+
write(c.gray(`${t("cli.searchableModels")}\n`));
|
|
274
|
+
for (const model of models) write(c.gray(`- ${model}\n`));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
write(c.gray(`${t("cli.currentModel", { model: harness.model })}\n`));
|
|
279
|
+
}
|
|
280
|
+
return true;
|
|
281
|
+
case "language":
|
|
282
|
+
if (!arg) {
|
|
283
|
+
write(
|
|
284
|
+
c.gray(
|
|
285
|
+
`${t("cli.language.current", {
|
|
286
|
+
language: harness.i18n.language,
|
|
287
|
+
configured: harness.i18n.configured || "auto",
|
|
288
|
+
source: harness.i18n.source || "",
|
|
289
|
+
})}\n`,
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
const next = harness.setLanguage(arg);
|
|
296
|
+
write(
|
|
297
|
+
c.gray(
|
|
298
|
+
`${next.t("cli.language.changed", {
|
|
299
|
+
language: next.language,
|
|
300
|
+
configured: next.configured || "auto",
|
|
301
|
+
source: next.source || "",
|
|
302
|
+
})}\n`,
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
statusBar.render(true);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
write(c.red(`${e.message}\n`));
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
case "usage": {
|
|
311
|
+
const u = harness.session.usage;
|
|
312
|
+
write(
|
|
313
|
+
c.gray(`${t("cli.usage", { prompt: u.promptTokens, completion: u.completionTokens, total: u.totalTokens })}\n`),
|
|
314
|
+
);
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
case "messages": {
|
|
318
|
+
const messages = listUserMessages(harness.session.paths, harness.session.id);
|
|
319
|
+
if (!messages.length) {
|
|
320
|
+
write(c.gray(`${t("cli.messagesNone")}\n`));
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
write(c.gray(`${t("cli.messagesHeader")}\n`));
|
|
324
|
+
for (const message of messages) write(c.gray(formatUserMessageRow(message) + "\n"));
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
case "skills": {
|
|
328
|
+
harness.reloadSkills?.();
|
|
329
|
+
if (!harness.skills.length) write(c.gray(`${t("cli.skillsNone")}\n`));
|
|
330
|
+
for (const s of harness.skills) write(c.gray(`- ${s.name}: ${s.description}\n`));
|
|
331
|
+
const summary = summarizeSkills(harness.skills);
|
|
332
|
+
write(c.gray(`\n${t("cli.skillsTotal", { count: summary.total })}\n`));
|
|
333
|
+
for (const item of summary.byDir) write(c.gray(`- ${item.dir}: ${item.count}\n`));
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
case "reload": {
|
|
337
|
+
const r = harness.reloadConfig({ force: true });
|
|
338
|
+
if (r.error) write(c.red(`${t("cli.reloadFailed", { error: r.error })}\n`));
|
|
339
|
+
else if (r.reloaded) write(c.gray(`${t("cli.reloadDone", { model: harness.model })}\n`));
|
|
340
|
+
else write(c.gray(`${t("cli.reloadUnchanged")}\n`));
|
|
341
|
+
statusBar.render(true);
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
case "auto-run":
|
|
345
|
+
if (arg === "off") {
|
|
346
|
+
harness.security.setApproval(harness.security.initialApproval);
|
|
347
|
+
write(c.gray(`${t("cli.approvalRestored", { approval: harness.security.approval })}\n`));
|
|
348
|
+
} else {
|
|
349
|
+
harness.security.setApproval("auto");
|
|
350
|
+
write(c.yellow(`${t("cli.autoRunEnabled")}\n`));
|
|
351
|
+
}
|
|
352
|
+
statusBar.render();
|
|
353
|
+
return true;
|
|
354
|
+
case "retry":
|
|
355
|
+
if (!lastInput) {
|
|
356
|
+
write(c.gray(`${t("cli.retryNone")}\n`));
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
return false; // 交回主循环用 lastInput 重跑
|
|
360
|
+
default:
|
|
361
|
+
write(c.red(`${t("cli.unknownSlash", { cmd })}\n`));
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 主循环
|
|
367
|
+
for (;;) {
|
|
368
|
+
statusBar.render();
|
|
369
|
+
let input;
|
|
370
|
+
try {
|
|
371
|
+
input = (await readInput()).trim();
|
|
372
|
+
} catch {
|
|
373
|
+
break; // rl 关闭
|
|
374
|
+
}
|
|
375
|
+
if (!input) continue;
|
|
376
|
+
tui?.echoInput(input);
|
|
377
|
+
|
|
378
|
+
if (input.startsWith("/")) {
|
|
379
|
+
const consumed = await handleSlash(input);
|
|
380
|
+
if (consumed) continue;
|
|
381
|
+
if (input.slice(1).startsWith("retry")) input = lastInput; // /retry
|
|
382
|
+
} else {
|
|
383
|
+
lastInput = input;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
running = true;
|
|
387
|
+
abort = new AbortController();
|
|
388
|
+
statusBar.setBusy(true);
|
|
389
|
+
try {
|
|
390
|
+
const result = await harness.runTurn(input, handlers, abort.signal);
|
|
391
|
+
if (result.maxIterations) write(c.yellow(`${t("cli.maxIterations")}\n`));
|
|
392
|
+
if (result.aborted) write(c.yellow(`${t("cli.aborted")}\n`));
|
|
393
|
+
} catch (e) {
|
|
394
|
+
write(c.red(`${t("cli.runtimeError", { message: e.message })}\n`));
|
|
395
|
+
} finally {
|
|
396
|
+
running = false;
|
|
397
|
+
abort = null;
|
|
398
|
+
statusBar.setBusy(false);
|
|
399
|
+
write("\n");
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
cleanup();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function printSessions(paths, i18n = createI18n("en")) {
|
|
407
|
+
const { t } = i18n;
|
|
408
|
+
const sessions = listSessions(paths);
|
|
409
|
+
if (!sessions.length) {
|
|
410
|
+
stdout.write(`${t("cli.sessionsNone")}\n`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
for (const s of sessions.slice(0, 20)) {
|
|
414
|
+
const when = new Date(s.mtime).toLocaleString();
|
|
415
|
+
stdout.write(`${t("cli.sessionRow", { id: s.id, count: s.messageCount, model: s.model, when })}\n`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// 统一消息协议。Core / Loop 全程使用 Message,仅在 LLM 边界转换为各 provider 格式。
|
|
2
|
+
|
|
3
|
+
import { genId, nowIso } from "../utils.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {"system"|"user"|"assistant"|"tool"} Role
|
|
7
|
+
* @typedef {{ id:string, name:string, arguments:any }} ToolCall
|
|
8
|
+
* @typedef {{
|
|
9
|
+
* id:string, role:Role, content:string,
|
|
10
|
+
* toolCalls?:ToolCall[], toolCallId?:string, name?:string,
|
|
11
|
+
* channel?:string, meta?:Record<string,any>
|
|
12
|
+
* }} Message
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function base(role, content, extra = {}) {
|
|
16
|
+
return {
|
|
17
|
+
id: genId("msg"),
|
|
18
|
+
role,
|
|
19
|
+
content: content ?? "",
|
|
20
|
+
meta: { timestamp: nowIso(), ...(extra.meta || {}) },
|
|
21
|
+
...stripMeta(extra),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stripMeta(extra) {
|
|
26
|
+
const { meta, ...rest } = extra;
|
|
27
|
+
return rest;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function systemMessage(content, extra = {}) {
|
|
31
|
+
return base("system", content, extra);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function userMessage(content, extra = {}) {
|
|
35
|
+
return base("user", content, extra);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** assistant 消息,可带 toolCalls。 */
|
|
39
|
+
export function assistantMessage(content, toolCalls = [], extra = {}) {
|
|
40
|
+
const msg = base("assistant", content, extra);
|
|
41
|
+
if (toolCalls && toolCalls.length) msg.toolCalls = toolCalls;
|
|
42
|
+
return msg;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** tool 结果消息,对应某次 toolCall。 */
|
|
46
|
+
export function toolMessage({ toolCallId, name, content, isError = false }) {
|
|
47
|
+
const msg = base("tool", content);
|
|
48
|
+
msg.toolCallId = toolCallId;
|
|
49
|
+
msg.name = name;
|
|
50
|
+
if (isError) msg.meta.isError = true;
|
|
51
|
+
return msg;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 校验角色交替规则(provider 强约束)。
|
|
56
|
+
* @returns {string[]} 违规说明,空表示通过。
|
|
57
|
+
*/
|
|
58
|
+
export function validateAlternation(messages) {
|
|
59
|
+
const errors = [];
|
|
60
|
+
let prev = null;
|
|
61
|
+
for (let i = 0; i < messages.length; i++) {
|
|
62
|
+
const m = messages[i];
|
|
63
|
+
if (m.role === "system" && i !== 0) {
|
|
64
|
+
errors.push(`#${i} system 消息只能位于开头`);
|
|
65
|
+
}
|
|
66
|
+
if (prev) {
|
|
67
|
+
if (prev.role === "assistant" && m.role === "assistant") {
|
|
68
|
+
errors.push(`#${i} 连续两条 assistant`);
|
|
69
|
+
}
|
|
70
|
+
if (prev.role === "user" && m.role === "user") {
|
|
71
|
+
errors.push(`#${i} 连续两条 user`);
|
|
72
|
+
}
|
|
73
|
+
// assistant 带 toolCalls 后,必须跟随 tool 结果
|
|
74
|
+
if (prev.role === "assistant" && prev.toolCalls?.length && m.role !== "tool") {
|
|
75
|
+
errors.push(`#${i} assistant 发起工具调用后应跟随 tool 结果`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
prev = m;
|
|
79
|
+
}
|
|
80
|
+
return errors;
|
|
81
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// 终端底部固定状态条:利用 ANSI 滚动区域(DECSTBM)将最后一行独立出来,
|
|
2
|
+
// 对话与输入在其上方滚动。常驻显示上下文用量 / 模型 / 工作区,忙碌时以旋转字符提示。
|
|
3
|
+
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { c } from "../utils.js";
|
|
6
|
+
|
|
7
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
8
|
+
|
|
9
|
+
function shortenPath(p, max = 36) {
|
|
10
|
+
const home = os.homedir();
|
|
11
|
+
let s = p && p.startsWith(home) ? "~" + p.slice(home.length) : p || "";
|
|
12
|
+
if (s.length > max) s = "…" + s.slice(-(max - 1));
|
|
13
|
+
return s;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class StatusBar {
|
|
17
|
+
/** @param {() => {used:number,total:number,model:string,cwd:string}} getState */
|
|
18
|
+
constructor(getState, { reservedInputLines = 0 } = {}) {
|
|
19
|
+
this.getState = getState;
|
|
20
|
+
this.reservedInputLines = reservedInputLines;
|
|
21
|
+
this.frame = 0;
|
|
22
|
+
this._loop = null;
|
|
23
|
+
this.busy = false;
|
|
24
|
+
this.enabled = false;
|
|
25
|
+
this._onResize = null;
|
|
26
|
+
this._last = ""; // 上次写入的状态行内容,用于脏检查防闪
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
enable() {
|
|
30
|
+
if (!process.stdout.isTTY) return; // 非 TTY(管道/重定向)不启用
|
|
31
|
+
this.enabled = true;
|
|
32
|
+
this._setRegion();
|
|
33
|
+
this._onResize = () => {
|
|
34
|
+
this._setRegion();
|
|
35
|
+
this.render(true); // 尺寸变化需强制重画
|
|
36
|
+
};
|
|
37
|
+
process.stdout.on("resize", this._onResize);
|
|
38
|
+
// 常驻刷新:忙碌时驱动 spinner 动画;空闲时内容不变,render 通过脏检查直接跳过,
|
|
39
|
+
// 既保证被意外擦除时能补画,又不会产生周期性抖动。
|
|
40
|
+
this._loop = setInterval(() => {
|
|
41
|
+
if (this.busy) this.frame++;
|
|
42
|
+
this.render();
|
|
43
|
+
}, 120);
|
|
44
|
+
this._loop.unref?.();
|
|
45
|
+
this.render(true);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_rows() {
|
|
49
|
+
return process.stdout.rows || 24;
|
|
50
|
+
}
|
|
51
|
+
_cols() {
|
|
52
|
+
return process.stdout.columns || 80;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
statusRow() {
|
|
56
|
+
return this._rows();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
inputRow() {
|
|
60
|
+
return Math.max(1, this.statusRow() - this.reservedInputLines);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
scrollBottom() {
|
|
64
|
+
return Math.max(1, this.inputRow() - 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 滚动区设为 1..scrollBottom,底部按需留给输入行与状态条
|
|
68
|
+
_setRegion() {
|
|
69
|
+
const bottom = this.scrollBottom();
|
|
70
|
+
process.stdout.write(`\x1b[1;${bottom}r\x1b[${bottom};1H`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
render(force = false) {
|
|
74
|
+
if (!this.enabled) return;
|
|
75
|
+
const rows = this.statusRow();
|
|
76
|
+
const cols = this._cols();
|
|
77
|
+
const st = this.getState() || {};
|
|
78
|
+
const used = st.used ?? 0;
|
|
79
|
+
const total = st.total ?? 0;
|
|
80
|
+
const pct = total ? Math.min(100, Math.round((used / total) * 100)) : 0;
|
|
81
|
+
const spin = this.busy ? FRAMES[this.frame % FRAMES.length] : "●";
|
|
82
|
+
|
|
83
|
+
const ctx = `ctx ${used}/${total} (${pct}%)`;
|
|
84
|
+
const rest = `${ctx} │ ${st.model || ""} │ ${shortenPath(st.cwd || "")}`;
|
|
85
|
+
const flag = st.approval === "auto" ? " ⚡auto" : "";
|
|
86
|
+
const budget = Math.max(0, cols - 4 - flag.length);
|
|
87
|
+
const text = rest.length > budget ? rest.slice(0, Math.max(0, budget - 1)) + "…" : rest;
|
|
88
|
+
|
|
89
|
+
const line = `${c.cyan(spin)}${flag ? c.yellow(flag) : ""} ${c.dim(text)}`;
|
|
90
|
+
// 脏检查:内容未变则不重写,避免空闲时周期性擦写造成闪烁
|
|
91
|
+
if (!force && line === this._last) return;
|
|
92
|
+
this._last = line;
|
|
93
|
+
// 保存光标 → 移到状态行行首 → 写内容 → 清到行尾(避免“先清整行”的空窗)→ 恢复光标
|
|
94
|
+
process.stdout.write(`\x1b7\x1b[${rows};1H${line}\x1b[K\x1b8`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 切换忙碌态:忙碌时 spinner 旋转,空闲时定格为静态符号;状态条始终常驻。 */
|
|
98
|
+
setBusy(b) {
|
|
99
|
+
this.busy = b;
|
|
100
|
+
this.render();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
disable() {
|
|
104
|
+
if (!this.enabled) return;
|
|
105
|
+
if (this._loop) {
|
|
106
|
+
clearInterval(this._loop);
|
|
107
|
+
this._loop = null;
|
|
108
|
+
}
|
|
109
|
+
if (this._onResize) process.stdout.off("resize", this._onResize);
|
|
110
|
+
const firstReservedRow = Math.min(this.inputRow(), this.statusRow());
|
|
111
|
+
// 重置滚动区,并清掉底部保留区域(固定输入行 + 状态栏)。
|
|
112
|
+
// 光标停在清理后的第一行,后续退出文案会写在干净位置,不残留状态栏内容。
|
|
113
|
+
process.stdout.write(`\x1b[r\x1b[${firstReservedRow};1H\x1b[J`);
|
|
114
|
+
this.enabled = false;
|
|
115
|
+
this._last = "";
|
|
116
|
+
}
|
|
117
|
+
}
|