mocode-ai 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/LICENSE +21 -0
- package/README.md +161 -0
- package/bin/mocode.js +4 -0
- package/dist/agent/index.js +156 -0
- package/dist/commands/config.js +61 -0
- package/dist/config/index.js +80 -0
- package/dist/index.js +75 -0
- package/dist/llm/index.js +162 -0
- package/dist/repl/index.js +530 -0
- package/dist/rollback/index.js +219 -0
- package/dist/session/compact.js +277 -0
- package/dist/session/index.js +8 -0
- package/dist/session/persist.js +109 -0
- package/dist/skills/discover.js +139 -0
- package/dist/skills/index.js +50 -0
- package/dist/tools/builtins/edit-file.js +34 -0
- package/dist/tools/builtins/glob.js +30 -0
- package/dist/tools/builtins/grep.js +62 -0
- package/dist/tools/builtins/index.js +24 -0
- package/dist/tools/builtins/read-file.js +34 -0
- package/dist/tools/builtins/run-command.js +49 -0
- package/dist/tools/builtins/use-skill.js +27 -0
- package/dist/tools/builtins/web-fetch.js +125 -0
- package/dist/tools/builtins/web-search.js +132 -0
- package/dist/tools/builtins/write-file.js +23 -0
- package/dist/tools/constants.js +11 -0
- package/dist/tools/registry.js +32 -0
- package/dist/tools/types.js +1 -0
- package/dist/ui/content.js +98 -0
- package/dist/ui/layout.js +609 -0
- package/dist/ui/prompt.js +414 -0
- package/dist/ui/render.js +204 -0
- package/dist/ui/spinner.js +63 -0
- package/dist/ui/theme.js +21 -0
- package/package.json +39 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { stdin, stdout } from 'node:process';
|
|
3
|
+
import { ui } from './theme.js';
|
|
4
|
+
import { displayWidth, padEndDisplay, truncateDisplay } from './render.js';
|
|
5
|
+
import * as layout from './layout.js';
|
|
6
|
+
/** 非 TTY / 未进 alt screen 时退化为普通 readline 行输入(无菜单、单行)。 */
|
|
7
|
+
function questionFallback(prompt) {
|
|
8
|
+
return new Promise((res, rej) => {
|
|
9
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
10
|
+
rl.question(prompt, (answer) => {
|
|
11
|
+
rl.close();
|
|
12
|
+
res(answer);
|
|
13
|
+
});
|
|
14
|
+
rl.on('error', (e) => {
|
|
15
|
+
rl.close();
|
|
16
|
+
rej(e);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 读多行输入;TUI 下经 layout.paintInput 把输入框画在固定底栏、斜杠菜单向上展开进内容区底。
|
|
22
|
+
* 换行:Ctrl+J / Alt+Enter / Shift+Enter(终端区分时)/ 粘贴的 LF。Enter 提交。返回行数组;null=空缓冲 Ctrl+D。
|
|
23
|
+
* 非 TTY 退化为 readline.question(单行,返回 [answer])。raw 模式下 Ctrl+C 先恢复终端再 reject('SIGINT')。
|
|
24
|
+
*
|
|
25
|
+
* 渲染全归 layout(prompt 只持有编辑状态:lines / 光标 / 菜单),prompt 不直接发 ANSI 区域控制,
|
|
26
|
+
* 避免 readline 光标错位与区域越界——颜色仅用在菜单行字符串里(由 layout 原样贴入)。
|
|
27
|
+
*/
|
|
28
|
+
export async function promptWithSlashMenu(opts) {
|
|
29
|
+
if (!layout.isActive()) {
|
|
30
|
+
const a = await questionFallback(opts.prompt);
|
|
31
|
+
return [a];
|
|
32
|
+
}
|
|
33
|
+
const emitter = stdin;
|
|
34
|
+
const promptW = displayWidth(opts.prompt);
|
|
35
|
+
// initialLines(运行中 typeahead 预填):用调用方给的行初始化,光标置末行末尾。
|
|
36
|
+
let lines = opts.initialLines && opts.initialLines.length > 0
|
|
37
|
+
? [...opts.initialLines]
|
|
38
|
+
: [''];
|
|
39
|
+
let cl = lines.length - 1; // 光标行(0-based)= 末行
|
|
40
|
+
let cc = lines[cl].length; // 光标在该行的字符索引 = 末行末尾
|
|
41
|
+
let menuOpen = false;
|
|
42
|
+
let selected = 0;
|
|
43
|
+
let filtered = [];
|
|
44
|
+
let resolved = false;
|
|
45
|
+
let resolve;
|
|
46
|
+
let reject;
|
|
47
|
+
/** 菜单行(预渲染,带色)——向上展开进内容区底,由 layout 贴入。 */
|
|
48
|
+
function menuLines() {
|
|
49
|
+
if (!menuOpen || filtered.length === 0)
|
|
50
|
+
return [];
|
|
51
|
+
const cols = layout.getGeo().cols;
|
|
52
|
+
const maxName = Math.max(...filtered.map((c) => displayWidth(c.name)));
|
|
53
|
+
return filtered.map((c, i) => {
|
|
54
|
+
const marker = i === selected ? `${ui.cyan}▸${ui.reset}` : ' ';
|
|
55
|
+
const name = padEndDisplay(c.name, maxName);
|
|
56
|
+
const descW = cols - maxName - 5; // marker + 空格 + 2 间距
|
|
57
|
+
const desc = descW > 0 ? truncateDisplay(c.desc, descW) : '';
|
|
58
|
+
return `${marker} ${ui.dim}${name}${ui.reset} ${ui.dim}${desc}${ui.reset}`;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** 当前光标在该行的显示列(供 layout 定位光标)。 */
|
|
62
|
+
function cursorCol() {
|
|
63
|
+
return displayWidth(lines[cl].slice(0, cc));
|
|
64
|
+
}
|
|
65
|
+
function computeFiltered() {
|
|
66
|
+
if (cl === 0 && lines[0].startsWith('/')) {
|
|
67
|
+
filtered = opts.commands.filter((c) => c.name.startsWith(lines[0]));
|
|
68
|
+
menuOpen = filtered.length > 0;
|
|
69
|
+
if (selected >= filtered.length)
|
|
70
|
+
selected = 0;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
filtered = [];
|
|
74
|
+
menuOpen = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function redraw() {
|
|
78
|
+
layout.paintInput({
|
|
79
|
+
prompt: opts.prompt,
|
|
80
|
+
lines,
|
|
81
|
+
cursorLine: cl,
|
|
82
|
+
cursorCol: cursorCol(),
|
|
83
|
+
menu: menuLines().length ? { lines: menuLines() } : null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function cleanup() {
|
|
87
|
+
try {
|
|
88
|
+
stdin.setRawMode(false);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// 忽略
|
|
92
|
+
}
|
|
93
|
+
emitter.removeListener('keypress', onKey);
|
|
94
|
+
stdin.pause();
|
|
95
|
+
}
|
|
96
|
+
function finish(value) {
|
|
97
|
+
if (resolved)
|
|
98
|
+
return;
|
|
99
|
+
resolved = true;
|
|
100
|
+
cleanup();
|
|
101
|
+
resolve(value);
|
|
102
|
+
}
|
|
103
|
+
/** 提交:菜单打开时先补全选中项到第 0 行。 */
|
|
104
|
+
function submit() {
|
|
105
|
+
if (menuOpen && filtered[selected]) {
|
|
106
|
+
lines = [filtered[selected].name];
|
|
107
|
+
cl = 0;
|
|
108
|
+
cc = lines[0].length;
|
|
109
|
+
}
|
|
110
|
+
finish(lines);
|
|
111
|
+
}
|
|
112
|
+
/** 插换行:在光标处断行。 */
|
|
113
|
+
function insertNewline() {
|
|
114
|
+
const after = lines[cl].slice(cc);
|
|
115
|
+
lines[cl] = lines[cl].slice(0, cc);
|
|
116
|
+
lines.splice(cl + 1, 0, after);
|
|
117
|
+
cl++;
|
|
118
|
+
cc = 0;
|
|
119
|
+
computeFiltered();
|
|
120
|
+
redraw();
|
|
121
|
+
}
|
|
122
|
+
function onKey(_str, key) {
|
|
123
|
+
if (resolved || !key)
|
|
124
|
+
return;
|
|
125
|
+
// 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑↓ 与 plain ↑/↓ 单行。
|
|
126
|
+
// plain ↑/↓ 仅在单行输入且菜单关闭时作滚动(多行编辑留给光标移动,菜单打开留给选项);
|
|
127
|
+
// 兼鼠标滚轮——WT alt 屏(经 \x1B[?1007h)滚轮转发 ↑/↓。
|
|
128
|
+
const plainArrowScroll = (key.name === 'up' || key.name === 'down') &&
|
|
129
|
+
!key.ctrl &&
|
|
130
|
+
!key.meta &&
|
|
131
|
+
!key.shift &&
|
|
132
|
+
lines.length <= 1 &&
|
|
133
|
+
!(menuOpen && filtered.length > 0);
|
|
134
|
+
if (key.name === 'pageup' ||
|
|
135
|
+
key.name === 'pagedown' ||
|
|
136
|
+
(key.ctrl && (key.name === 'up' || key.name === 'down')) ||
|
|
137
|
+
plainArrowScroll) {
|
|
138
|
+
const pageH = layout.getGeo().contentBottom;
|
|
139
|
+
if (key.name === 'pageup')
|
|
140
|
+
layout.scrollBy(pageH);
|
|
141
|
+
else if (key.name === 'pagedown')
|
|
142
|
+
layout.scrollBy(-pageH);
|
|
143
|
+
else if (key.name === 'up')
|
|
144
|
+
layout.scrollBy(1);
|
|
145
|
+
else
|
|
146
|
+
layout.scrollBy(-1);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// 其他键:若处于滚动回看,先回尾再处理(打字即回底)
|
|
150
|
+
if (layout.isScrolled())
|
|
151
|
+
layout.resetScroll();
|
|
152
|
+
// raw 模式下 Ctrl+C 不触发 SIGINT,作为按键到达:先恢复终端再 reject
|
|
153
|
+
if (key.ctrl && key.name === 'c') {
|
|
154
|
+
cleanup();
|
|
155
|
+
reject(new Error('SIGINT'));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (key.ctrl && key.name === 'd') {
|
|
159
|
+
if (lines.every((l) => l === '') && cl === 0 && cc === 0)
|
|
160
|
+
finish(null);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (key.ctrl && key.name === 'a') {
|
|
164
|
+
cc = 0;
|
|
165
|
+
redraw();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (key.ctrl && key.name === 'e') {
|
|
169
|
+
cc = lines[cl].length;
|
|
170
|
+
redraw();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const isReturn = key.name === 'return' || key.name === 'enter';
|
|
174
|
+
// 换行:Ctrl+J / Alt+Enter(meta)/ Shift+Enter(终端区分时)/ 粘贴的 LF
|
|
175
|
+
const wantNewline = (key.ctrl && key.name === 'j') ||
|
|
176
|
+
(key.meta && isReturn) ||
|
|
177
|
+
(key.shift && isReturn) ||
|
|
178
|
+
(key.sequence === '\n' && !key.ctrl);
|
|
179
|
+
// 换行(Ctrl+J / Alt+Enter / Shift+Enter / 粘贴 LF)
|
|
180
|
+
if (wantNewline) {
|
|
181
|
+
insertNewline();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// 提交(plain Enter)
|
|
185
|
+
if (isReturn && !key.shift && !key.meta && !key.ctrl) {
|
|
186
|
+
submit();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
switch (key.name) {
|
|
190
|
+
case 'backspace':
|
|
191
|
+
if (cc > 0) {
|
|
192
|
+
lines[cl] = lines[cl].slice(0, cc - 1) + lines[cl].slice(cc);
|
|
193
|
+
cc--;
|
|
194
|
+
computeFiltered();
|
|
195
|
+
redraw();
|
|
196
|
+
}
|
|
197
|
+
else if (cl > 0) {
|
|
198
|
+
// 行首退格:并入上一行
|
|
199
|
+
const cur = lines[cl];
|
|
200
|
+
cc = lines[cl - 1].length;
|
|
201
|
+
lines[cl - 1] = lines[cl - 1] + cur;
|
|
202
|
+
lines.splice(cl, 1);
|
|
203
|
+
cl--;
|
|
204
|
+
computeFiltered();
|
|
205
|
+
redraw();
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
case 'up':
|
|
209
|
+
if (menuOpen && filtered.length) {
|
|
210
|
+
selected = (selected - 1 + filtered.length) % filtered.length;
|
|
211
|
+
redraw();
|
|
212
|
+
}
|
|
213
|
+
else if (cl > 0) {
|
|
214
|
+
cl--;
|
|
215
|
+
cc = Math.min(cc, lines[cl].length);
|
|
216
|
+
redraw();
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
case 'down':
|
|
220
|
+
if (menuOpen && filtered.length) {
|
|
221
|
+
selected = (selected + 1) % filtered.length;
|
|
222
|
+
redraw();
|
|
223
|
+
}
|
|
224
|
+
else if (cl < lines.length - 1) {
|
|
225
|
+
cl++;
|
|
226
|
+
cc = Math.min(cc, lines[cl].length);
|
|
227
|
+
redraw();
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
case 'tab':
|
|
231
|
+
if (menuOpen && filtered[selected]) {
|
|
232
|
+
lines[0] = filtered[selected].name;
|
|
233
|
+
cl = 0;
|
|
234
|
+
cc = lines[0].length;
|
|
235
|
+
computeFiltered();
|
|
236
|
+
redraw();
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
case 'escape':
|
|
240
|
+
menuOpen = false;
|
|
241
|
+
filtered = [];
|
|
242
|
+
redraw();
|
|
243
|
+
return;
|
|
244
|
+
case 'left':
|
|
245
|
+
if (cc > 0) {
|
|
246
|
+
cc--;
|
|
247
|
+
redraw();
|
|
248
|
+
}
|
|
249
|
+
else if (cl > 0) {
|
|
250
|
+
cl--;
|
|
251
|
+
cc = lines[cl].length;
|
|
252
|
+
redraw();
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
case 'right':
|
|
256
|
+
if (cc < lines[cl].length) {
|
|
257
|
+
cc++;
|
|
258
|
+
redraw();
|
|
259
|
+
}
|
|
260
|
+
else if (cl < lines.length - 1) {
|
|
261
|
+
cl++;
|
|
262
|
+
cc = 0;
|
|
263
|
+
redraw();
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
case 'home':
|
|
267
|
+
cc = 0;
|
|
268
|
+
redraw();
|
|
269
|
+
return;
|
|
270
|
+
case 'end':
|
|
271
|
+
cc = lines[cl].length;
|
|
272
|
+
redraw();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// 可打印字符(>= 空格,非 ctrl/meta)
|
|
276
|
+
const s = key.sequence ?? '';
|
|
277
|
+
if (s && s >= ' ' && !key.ctrl && !key.meta) {
|
|
278
|
+
lines[cl] = lines[cl].slice(0, cc) + s + lines[cl].slice(cc);
|
|
279
|
+
cc += s.length;
|
|
280
|
+
computeFiltered();
|
|
281
|
+
redraw();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return new Promise((res, rej) => {
|
|
285
|
+
resolve = res;
|
|
286
|
+
reject = rej;
|
|
287
|
+
readline.emitKeypressEvents(stdin);
|
|
288
|
+
let rawOk = true;
|
|
289
|
+
try {
|
|
290
|
+
stdin.setRawMode(true);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
rawOk = false;
|
|
294
|
+
}
|
|
295
|
+
if (!rawOk) {
|
|
296
|
+
// isTTY 但 setRawMode 失败(罕见):退化为 readline
|
|
297
|
+
questionFallback(opts.prompt).then((a) => res([a]), (e) => rej(e));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
stdin.resume();
|
|
301
|
+
emitter.on('keypress', onKey);
|
|
302
|
+
computeFiltered();
|
|
303
|
+
redraw();
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* 轮次选择菜单(供 /rollback 菜单化选择):↑/↓ 导航、Enter 选中、Esc/Ctrl+D 取消。
|
|
308
|
+
* 把 items 画成向上展开的菜单(经 layout.paintInput,与斜杠菜单同套渲染),输入框行作操作提示。
|
|
309
|
+
* 返回选中的 0-based 下标;null=取消 / 非 TTY / 空列表。纯导航(不收文本输入)。
|
|
310
|
+
* 长列表(超屏高)自动开窗保光标可见;默认聚焦末项(最新轮次,靠近输入框)。
|
|
311
|
+
*/
|
|
312
|
+
export async function promptTurnPicker(items) {
|
|
313
|
+
if (!layout.isActive() || items.length === 0)
|
|
314
|
+
return null;
|
|
315
|
+
const emitter = stdin;
|
|
316
|
+
const hint = '↑↓ 选择 · Enter 回滚到该轮 · Esc 取消';
|
|
317
|
+
let selected = items.length - 1; // 默认聚焦最新(末项,菜单底、靠近输入框)
|
|
318
|
+
let resolved = false;
|
|
319
|
+
let resolve;
|
|
320
|
+
let reject;
|
|
321
|
+
/** 菜单行(带开窗):超屏高时以 selected 为中心取窗,保光标可见;末项在底(靠近输入框)。 */
|
|
322
|
+
function menuLines() {
|
|
323
|
+
const g = layout.getGeo();
|
|
324
|
+
const maxRows = Math.max(1, g.contentBottom);
|
|
325
|
+
let start = 0;
|
|
326
|
+
if (items.length > maxRows) {
|
|
327
|
+
start = Math.max(0, Math.min(selected - Math.floor(maxRows / 2), items.length - maxRows));
|
|
328
|
+
}
|
|
329
|
+
const count = Math.min(maxRows, items.length);
|
|
330
|
+
const cols = g.cols;
|
|
331
|
+
return Array.from({ length: count }, (_, i) => {
|
|
332
|
+
const idx = start + i;
|
|
333
|
+
const marker = idx === selected ? `${ui.cyan}▸${ui.reset}` : ' ';
|
|
334
|
+
const num = `${ui.dim}${idx + 1}${ui.reset}`;
|
|
335
|
+
const text = truncateDisplay(items[idx].firstLine, cols - 6);
|
|
336
|
+
return `${marker} ${num} ${ui.dim}${text}${ui.reset}`;
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
function redraw() {
|
|
340
|
+
layout.paintInput({
|
|
341
|
+
prompt: '❯ ',
|
|
342
|
+
lines: [hint],
|
|
343
|
+
cursorLine: 0,
|
|
344
|
+
cursorCol: displayWidth(hint),
|
|
345
|
+
menu: { lines: menuLines() },
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
function cleanup() {
|
|
349
|
+
try {
|
|
350
|
+
stdin.setRawMode(false);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// 忽略
|
|
354
|
+
}
|
|
355
|
+
emitter.removeListener('keypress', onKey);
|
|
356
|
+
stdin.pause();
|
|
357
|
+
}
|
|
358
|
+
function finish(value) {
|
|
359
|
+
if (resolved)
|
|
360
|
+
return;
|
|
361
|
+
resolved = true;
|
|
362
|
+
cleanup();
|
|
363
|
+
resolve(value);
|
|
364
|
+
}
|
|
365
|
+
function onKey(_str, key) {
|
|
366
|
+
if (resolved || !key)
|
|
367
|
+
return;
|
|
368
|
+
if (key.ctrl && key.name === 'c') {
|
|
369
|
+
cleanup();
|
|
370
|
+
reject(new Error('SIGINT'));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (key.ctrl && key.name === 'd') {
|
|
374
|
+
finish(null);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
switch (key.name) {
|
|
378
|
+
case 'up':
|
|
379
|
+
selected = (selected - 1 + items.length) % items.length;
|
|
380
|
+
redraw();
|
|
381
|
+
return;
|
|
382
|
+
case 'down':
|
|
383
|
+
selected = (selected + 1) % items.length;
|
|
384
|
+
redraw();
|
|
385
|
+
return;
|
|
386
|
+
case 'return':
|
|
387
|
+
case 'enter':
|
|
388
|
+
finish(selected);
|
|
389
|
+
return;
|
|
390
|
+
case 'escape':
|
|
391
|
+
finish(null);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return new Promise((res, rej) => {
|
|
396
|
+
resolve = res;
|
|
397
|
+
reject = rej;
|
|
398
|
+
readline.emitKeypressEvents(stdin);
|
|
399
|
+
let rawOk = true;
|
|
400
|
+
try {
|
|
401
|
+
stdin.setRawMode(true);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
rawOk = false;
|
|
405
|
+
}
|
|
406
|
+
if (!rawOk) {
|
|
407
|
+
res(null);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
stdin.resume();
|
|
411
|
+
emitter.on('keypress', onKey);
|
|
412
|
+
redraw();
|
|
413
|
+
});
|
|
414
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { stdout } from 'node:process';
|
|
2
|
+
import { ui } from './theme.js';
|
|
3
|
+
/**
|
|
4
|
+
* 清空整屏 + 滚动缓冲(向上滚动可见的历史输出),光标归位。
|
|
5
|
+
* 进入会话时调用,让终端只剩当前 agent 对话。非 TTY 时空操作。
|
|
6
|
+
*/
|
|
7
|
+
export function clearScreen() {
|
|
8
|
+
if (!ui.isTTY)
|
|
9
|
+
return;
|
|
10
|
+
// [2J 清整屏 · [3J 清滚动缓冲 · [H 光标回到左上
|
|
11
|
+
stdout.write('\x1B[2J\x1B[3J\x1B[H');
|
|
12
|
+
}
|
|
13
|
+
// ── 显示宽度:CJK / 全角算 2,控制符 / 组合符算 0;用于横幅边框对齐 ──
|
|
14
|
+
export function charWidth(cp) {
|
|
15
|
+
if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0))
|
|
16
|
+
return 0; // 控制符
|
|
17
|
+
if (cp >= 0x300 && cp <= 0x36f)
|
|
18
|
+
return 0; // 组合附加符号
|
|
19
|
+
if (cp >= 0x1ab0 && cp <= 0x1aff)
|
|
20
|
+
return 0;
|
|
21
|
+
if (cp >= 0x1dc0 && cp <= 0x1dff)
|
|
22
|
+
return 0;
|
|
23
|
+
if (cp >= 0x20d0 && cp <= 0x20ff)
|
|
24
|
+
return 0;
|
|
25
|
+
if (cp >= 0x200b && cp <= 0x200f)
|
|
26
|
+
return 0; // 零宽 / ZWJ
|
|
27
|
+
if (cp >= 0xfe00 && cp <= 0xfe0f)
|
|
28
|
+
return 0; // 变体选择符
|
|
29
|
+
if (cp >= 0x1100 && cp <= 0x115f)
|
|
30
|
+
return 2;
|
|
31
|
+
if (cp >= 0x2e80 && cp <= 0x303e)
|
|
32
|
+
return 2;
|
|
33
|
+
if (cp >= 0x3041 && cp <= 0x33ff)
|
|
34
|
+
return 2;
|
|
35
|
+
if (cp >= 0x3400 && cp <= 0x4dbf)
|
|
36
|
+
return 2;
|
|
37
|
+
if (cp >= 0x4e00 && cp <= 0xa4cf)
|
|
38
|
+
return 2; // CJK 统一表意
|
|
39
|
+
if (cp >= 0xac00 && cp <= 0xd7a3)
|
|
40
|
+
return 2; // 韩文音节
|
|
41
|
+
if (cp >= 0xf900 && cp <= 0xfaff)
|
|
42
|
+
return 2;
|
|
43
|
+
if (cp >= 0xfe30 && cp <= 0xfe6f)
|
|
44
|
+
return 2;
|
|
45
|
+
if (cp >= 0xff00 && cp <= 0xff60)
|
|
46
|
+
return 2; // 全角
|
|
47
|
+
if (cp >= 0xffe0 && cp <= 0xffe6)
|
|
48
|
+
return 2;
|
|
49
|
+
if (cp >= 0x1f300 && cp <= 0x1faff)
|
|
50
|
+
return 2; // 表情
|
|
51
|
+
if (cp >= 0x20000 && cp <= 0x2fffd)
|
|
52
|
+
return 2; // CJK 扩展 B-F
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
export function displayWidth(str) {
|
|
56
|
+
let w = 0;
|
|
57
|
+
for (const ch of str)
|
|
58
|
+
w += charWidth(ch.codePointAt(0) ?? 0);
|
|
59
|
+
return w;
|
|
60
|
+
}
|
|
61
|
+
/** 去除 SGR 颜色转义(\x1B[…m),用于度量带色串的真实可见宽度。 */
|
|
62
|
+
export function stripAnsi(s) {
|
|
63
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
64
|
+
}
|
|
65
|
+
/** 带色串的可见显示宽度(先去 ANSI 再按 displayWidth 度量)。 */
|
|
66
|
+
export function ansiDisplayWidth(s) {
|
|
67
|
+
return displayWidth(stripAnsi(s));
|
|
68
|
+
}
|
|
69
|
+
/** 按显示宽度右补空格。 */
|
|
70
|
+
export function padEndDisplay(str, width) {
|
|
71
|
+
const w = displayWidth(str);
|
|
72
|
+
return w >= width ? str : str + ' '.repeat(width - w);
|
|
73
|
+
}
|
|
74
|
+
/** 按显示宽度截断,超出加 …。 */
|
|
75
|
+
export function truncateDisplay(str, width) {
|
|
76
|
+
if (displayWidth(str) <= width)
|
|
77
|
+
return str;
|
|
78
|
+
let w = 0;
|
|
79
|
+
let out = '';
|
|
80
|
+
for (const ch of str) {
|
|
81
|
+
const cw = charWidth(ch.codePointAt(0) ?? 0);
|
|
82
|
+
if (w + cw + 1 > width)
|
|
83
|
+
break; // +1 留给末尾的 …
|
|
84
|
+
out += ch;
|
|
85
|
+
w += cw;
|
|
86
|
+
}
|
|
87
|
+
return out + '…';
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 按显示宽度把文本软折行为多行(供输入框软换行):每行可见宽度 ≤ width。
|
|
91
|
+
* 宽字符(CJK / emoji = 2)在剩余宽度放不下时整字折到下行(留尾部空格,与终端自动折行一致),
|
|
92
|
+
* 而非劈开半个字。零宽字符(组合符等)不计宽度、附在当前行。空串返回 [''](占一行)。
|
|
93
|
+
* 输入文本应全为可见字符(无控制码 / SGR)。
|
|
94
|
+
*/
|
|
95
|
+
export function wrapByDisplayWidth(text, width) {
|
|
96
|
+
const w = Math.max(1, width);
|
|
97
|
+
const out = [];
|
|
98
|
+
let cur = '';
|
|
99
|
+
let curW = 0;
|
|
100
|
+
for (const ch of text) {
|
|
101
|
+
const cw = charWidth(ch.codePointAt(0) ?? 0);
|
|
102
|
+
if (cw <= 0) {
|
|
103
|
+
cur += ch; // 零宽:不计宽度
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (curW + cw > w) {
|
|
107
|
+
out.push(cur);
|
|
108
|
+
cur = ch;
|
|
109
|
+
curW = cw;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
cur += ch;
|
|
113
|
+
curW += cw;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
out.push(cur);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
const BOX_W = 76; // 内容区显示宽度(容下完整工具列表 + 命令提示)
|
|
120
|
+
const MARGIN = ' '; // 盒外左缩进
|
|
121
|
+
function boxBorder(left, mid, right) {
|
|
122
|
+
return `${ui.gray}${left}${mid.repeat(BOX_W + 2)}${right}${ui.reset}`;
|
|
123
|
+
}
|
|
124
|
+
function boxLine(content) {
|
|
125
|
+
const inner = padEndDisplay(truncateDisplay(content, BOX_W), BOX_W);
|
|
126
|
+
return `${ui.gray}│${ui.reset} ${inner} ${ui.gray}│${ui.reset}`;
|
|
127
|
+
}
|
|
128
|
+
function boxEmpty() {
|
|
129
|
+
return `${ui.gray}│${ui.reset} ${' '.repeat(BOX_W)} ${ui.gray}│${ui.reset}`;
|
|
130
|
+
}
|
|
131
|
+
function labelRow(label, value) {
|
|
132
|
+
return boxLine(`${ui.dim}${padEndDisplay(label, 6)}${ui.reset}${value}`);
|
|
133
|
+
}
|
|
134
|
+
/** 横幅纯文本(带 ANSI 颜色,不写出)——供 TUI 经 contentWrite 写入内容区以跟踪续写位。 */
|
|
135
|
+
export function bannerString(info) {
|
|
136
|
+
const rows = [
|
|
137
|
+
boxBorder('╭', '─', '╮'),
|
|
138
|
+
boxLine(`${ui.bold}${ui.brightCyan}◆ mocode${ui.reset} ${ui.dim}终端编码 agent${ui.reset}`),
|
|
139
|
+
boxEmpty(),
|
|
140
|
+
labelRow('模型', info.model),
|
|
141
|
+
labelRow('后端', info.baseURL),
|
|
142
|
+
labelRow('目录', info.cwd),
|
|
143
|
+
labelRow('工具', info.tools),
|
|
144
|
+
boxEmpty(),
|
|
145
|
+
boxLine(`${ui.dim}/exit 退出 · /clear 清空 · /compact 压缩 · /context 用量 · /resume 续接${ui.reset}`),
|
|
146
|
+
boxBorder('╰', '─', '╯'),
|
|
147
|
+
];
|
|
148
|
+
return (rows.map((r) => MARGIN + r).join('\n') +
|
|
149
|
+
'\n\n' +
|
|
150
|
+
`${MARGIN}${ui.dim}直接描述任务,agent 会自动读写文件与执行命令。${ui.reset}\n`);
|
|
151
|
+
}
|
|
152
|
+
/** 启动横幅:带边框的信息盒 + 一行提示。纯渲染,不依赖 config / 业务。 */
|
|
153
|
+
export function printBanner(info) {
|
|
154
|
+
stdout.write(bannerString(info));
|
|
155
|
+
}
|
|
156
|
+
// ── 工具调用 / 结果 摘要 ──
|
|
157
|
+
function tryParse(raw) {
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(raw);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** 把工具调用的 JSON 参数提炼成人可读的一行(路径 / 模式 / 命令)。 */
|
|
166
|
+
export function summarizeToolCall(name, argsRaw) {
|
|
167
|
+
const args = tryParse(argsRaw);
|
|
168
|
+
if (!args)
|
|
169
|
+
return truncateDisplay(argsRaw, 80);
|
|
170
|
+
const s = (k) => typeof args[k] === 'string' ? args[k] : '';
|
|
171
|
+
switch (name) {
|
|
172
|
+
case 'read_file':
|
|
173
|
+
case 'write_file':
|
|
174
|
+
case 'edit_file':
|
|
175
|
+
return s('path') || truncateDisplay(argsRaw, 80);
|
|
176
|
+
case 'run_command':
|
|
177
|
+
return truncateDisplay(s('command') || argsRaw, 100);
|
|
178
|
+
case 'glob':
|
|
179
|
+
return s('pattern') || argsRaw;
|
|
180
|
+
case 'grep': {
|
|
181
|
+
const p = s('pattern');
|
|
182
|
+
const path = s('path');
|
|
183
|
+
return path ? `${p} · ${path}` : p || argsRaw;
|
|
184
|
+
}
|
|
185
|
+
default:
|
|
186
|
+
return truncateDisplay(argsRaw, 80);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** 工具结果的人可读一行预览(行数 / 匹配数 / 首行);喂回 LLM 的全文不变。 */
|
|
190
|
+
export function summarizeToolResult(name, output) {
|
|
191
|
+
const nonEmpty = output.split('\n').filter((l) => l.trim().length > 0);
|
|
192
|
+
switch (name) {
|
|
193
|
+
case 'read_file':
|
|
194
|
+
return nonEmpty.length ? `${nonEmpty.length} 行` : '(空文件)';
|
|
195
|
+
case 'glob':
|
|
196
|
+
return nonEmpty.length ? `${nonEmpty.length} 个文件` : '(无匹配)';
|
|
197
|
+
case 'grep':
|
|
198
|
+
return nonEmpty.length ? `${nonEmpty.length} 处匹配` : '(无匹配)';
|
|
199
|
+
case 'run_command':
|
|
200
|
+
return truncateDisplay(nonEmpty[0] ?? '', 100) || '(无输出)';
|
|
201
|
+
default:
|
|
202
|
+
return truncateDisplay(nonEmpty[0] ?? '', 100);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { stdout } from 'node:process';
|
|
2
|
+
import { ui } from './theme.js';
|
|
3
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
4
|
+
/**
|
|
5
|
+
* 等待动画:在 await 长操作(chat / 工具执行 / 压缩)时旋转,避免「卡死」错觉。
|
|
6
|
+
*
|
|
7
|
+
* 两种渲染模式:
|
|
8
|
+
* - onFrame 回调(TUI 态):帧经回调刷状态行(layout.drawStatusBar),内容区保持静止、底栏转圈;
|
|
9
|
+
* 不在内容行用 \r\x1B[K 原地刷(那会擦内容、与滚动区域冲突)。
|
|
10
|
+
* - 无回调(非 TTY / 旧路径):\r\x1B[K 原地刷 braille 帧;非 TTY 退化为启动时打印一行静态提示。
|
|
11
|
+
*
|
|
12
|
+
* stop() 仅在确实旋转过(有 timer)时清场,避免对从未 start 的实例误清状态行。
|
|
13
|
+
*/
|
|
14
|
+
export class Spinner {
|
|
15
|
+
onFrame;
|
|
16
|
+
timer = null;
|
|
17
|
+
frame = 0;
|
|
18
|
+
msg = '';
|
|
19
|
+
constructor(onFrame) {
|
|
20
|
+
this.onFrame = onFrame;
|
|
21
|
+
}
|
|
22
|
+
start(msg) {
|
|
23
|
+
this.stop();
|
|
24
|
+
this.msg = msg;
|
|
25
|
+
if (!ui.isTTY) {
|
|
26
|
+
stdout.write(`${ui.dim}${msg}…${ui.reset}\n`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (this.onFrame) {
|
|
30
|
+
const cb = this.onFrame;
|
|
31
|
+
this.frame = 0;
|
|
32
|
+
cb(msg, FRAMES[0]); // 立即首帧,状态行即刻反映
|
|
33
|
+
this.timer = setInterval(() => {
|
|
34
|
+
this.frame = (this.frame + 1) % FRAMES.length;
|
|
35
|
+
cb(msg, FRAMES[this.frame]);
|
|
36
|
+
}, 80);
|
|
37
|
+
this.timer.unref();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.frame = 0;
|
|
41
|
+
this.timer = setInterval(() => {
|
|
42
|
+
this.render();
|
|
43
|
+
this.frame = (this.frame + 1) % FRAMES.length;
|
|
44
|
+
}, 80);
|
|
45
|
+
this.timer.unref();
|
|
46
|
+
}
|
|
47
|
+
render() {
|
|
48
|
+
const f = FRAMES[this.frame];
|
|
49
|
+
stdout.write(`\r${ui.brightMagenta}${f}${ui.reset} ${ui.dim}${this.msg}${ui.reset}\x1B[K`);
|
|
50
|
+
}
|
|
51
|
+
stop() {
|
|
52
|
+
if (!this.timer)
|
|
53
|
+
return; // 未旋转:不清场(避免误清状态行)
|
|
54
|
+
clearInterval(this.timer);
|
|
55
|
+
this.timer = null;
|
|
56
|
+
if (this.onFrame) {
|
|
57
|
+
this.onFrame(this.msg, null); // 状态行去帧,保留状态文字
|
|
58
|
+
}
|
|
59
|
+
else if (ui.isTTY) {
|
|
60
|
+
stdout.write('\r\x1B[K');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/ui/theme.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { stdout } from 'node:process';
|
|
2
|
+
/**
|
|
3
|
+
* 终端 UI 主题:TTY 感知的 ANSI 颜色。
|
|
4
|
+
* 非 TTY(管道 / 重定向)时颜色退化为空串,避免把转义码原样打到日志里。
|
|
5
|
+
*/
|
|
6
|
+
const isTTY = Boolean(stdout.isTTY);
|
|
7
|
+
const wrap = (code) => (isTTY ? code : '');
|
|
8
|
+
export const ui = {
|
|
9
|
+
isTTY,
|
|
10
|
+
reset: wrap('\x1B[0m'),
|
|
11
|
+
bold: wrap('\x1B[1m'),
|
|
12
|
+
dim: wrap('\x1B[2m'),
|
|
13
|
+
red: wrap('\x1B[31m'),
|
|
14
|
+
green: wrap('\x1B[32m'),
|
|
15
|
+
yellow: wrap('\x1B[33m'),
|
|
16
|
+
cyan: wrap('\x1B[36m'),
|
|
17
|
+
gray: wrap('\x1B[90m'),
|
|
18
|
+
magenta: wrap('\x1B[35m'),
|
|
19
|
+
brightCyan: wrap('\x1B[96m'),
|
|
20
|
+
brightMagenta: wrap('\x1B[95m'),
|
|
21
|
+
};
|