mocode-ai 0.1.2 → 0.1.4
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.md +34 -7
- package/dist/agent/index.js +134 -60
- package/dist/config/index.js +11 -0
- package/dist/memory/discover.js +49 -0
- package/dist/memory/index.js +45 -0
- package/dist/memory/reflect.js +265 -0
- package/dist/memory/store.js +339 -0
- package/dist/repl/index.js +133 -24
- package/dist/session/compact.js +50 -2
- package/dist/tools/builtins/ask-human.js +50 -0
- package/dist/tools/builtins/index.js +12 -0
- package/dist/tools/builtins/memory-forget.js +32 -0
- package/dist/tools/builtins/memory-list.js +34 -0
- package/dist/tools/builtins/memory-save.js +51 -0
- package/dist/tools/builtins/memory-search.js +42 -0
- package/dist/tools/builtins/memory-update.js +41 -0
- package/dist/tools/builtins/use-skill.js +1 -1
- package/dist/tools/builtins/web-fetch.js +1 -1
- package/dist/tools/builtins/web-search.js +1 -1
- package/dist/tools/constants.js +13 -0
- package/dist/ui/intervention.js +297 -0
- package/dist/ui/layout.js +115 -30
- package/dist/ui/prompt.js +335 -18
- package/dist/ui/render.js +18 -0
- package/dist/ui/spinner.js +15 -0
- package/dist/ui/theme.js +3 -0
- package/package.json +1 -1
package/dist/ui/prompt.js
CHANGED
|
@@ -3,6 +3,42 @@ import { stdin, stdout } from 'node:process';
|
|
|
3
3
|
import { ui } from './theme.js';
|
|
4
4
|
import { displayWidth, padEndDisplay, truncateDisplay } from './render.js';
|
|
5
5
|
import * as layout from './layout.js';
|
|
6
|
+
// ── 粘贴检测(块级 + 时间窗)──
|
|
7
|
+
// 块级:多字节大块(len>8)或含 CR/LF 的小块 = 粘贴(键盘单键 1 字节,Enter=\r 单字节)。
|
|
8
|
+
// 时间窗:每块重置 50ms 计时器,静默 50ms 即"粘贴结束"→ onPasteEnd。跨多块的大粘贴(块间 <50ms)累积进
|
|
9
|
+
// 同一个 pasteParts、末尾一次性落 chip——避免"首块成 chip、后续块泄成文本"。粘贴中 onKey 把键累积进
|
|
10
|
+
// pasteParts(不编辑 lines)。不启用 bracketed paste——emitKeypressEvents 会把 \x1B[200~ 标记当按键砸进输入框。
|
|
11
|
+
let pasting = false;
|
|
12
|
+
let pasteParts = [];
|
|
13
|
+
let pasteTimer = null;
|
|
14
|
+
let onPasteEnd = null; // 粘贴结束回调(prompt 注入:落 chip 或保留为文本)
|
|
15
|
+
let pasteDetectorInstalled = false;
|
|
16
|
+
function ensurePasteDetector() {
|
|
17
|
+
if (pasteDetectorInstalled)
|
|
18
|
+
return;
|
|
19
|
+
pasteDetectorInstalled = true;
|
|
20
|
+
stdin.on('data', (chunk) => {
|
|
21
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
22
|
+
const hasNL = text.indexOf('\r') >= 0 || text.indexOf('\n') >= 0;
|
|
23
|
+
// 按字符数(码点)判粘贴,非字节:CJK 汉字占 3 UTF-8 字节,旧阈值(len>8 字节)会把 IME 提交的
|
|
24
|
+
// 3-10 个汉字误判为粘贴 → 进 50ms 缓冲 → finalizePaste→insertText 落字(且旧 insertText 把光标
|
|
25
|
+
// 置插入文本长度而非末尾,致"后续打字插到行中间")。改:含换行(多行粘贴)或 >16 字符(大块单行
|
|
26
|
+
// 粘贴)才算粘贴;普通 IME 提交走正常按键路径(逐字直插、光标随进、无延迟)。
|
|
27
|
+
const charCount = [...text].length;
|
|
28
|
+
if (!(charCount > 16 || (charCount > 1 && hasNL)))
|
|
29
|
+
return;
|
|
30
|
+
pasting = true;
|
|
31
|
+
if (pasteTimer)
|
|
32
|
+
clearTimeout(pasteTimer);
|
|
33
|
+
const t = setTimeout(() => {
|
|
34
|
+
pasteTimer = null;
|
|
35
|
+
pasting = false;
|
|
36
|
+
onPasteEnd?.();
|
|
37
|
+
}, 50);
|
|
38
|
+
t.unref();
|
|
39
|
+
pasteTimer = t;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
6
42
|
/** 非 TTY / 未进 alt screen 时退化为普通 readline 行输入(无菜单、单行)。 */
|
|
7
43
|
function questionFallback(prompt) {
|
|
8
44
|
return new Promise((res, rej) => {
|
|
@@ -38,6 +74,8 @@ export async function promptWithSlashMenu(opts) {
|
|
|
38
74
|
: [''];
|
|
39
75
|
let cl = lines.length - 1; // 光标行(0-based)= 末行
|
|
40
76
|
let cc = lines[cl].length; // 光标在该行的字符索引 = 末行末尾
|
|
77
|
+
let justSawCR = false; // \r\n 合一:粘贴的 \r 折行后,紧跟的 \n 吞掉(避免折两行)
|
|
78
|
+
let chip = null; // 原子粘贴块:整段封进 [预览…],不可编辑;提交时拼回全文
|
|
41
79
|
let menuOpen = false;
|
|
42
80
|
let selected = 0;
|
|
43
81
|
let filtered = [];
|
|
@@ -51,17 +89,74 @@ export async function promptWithSlashMenu(opts) {
|
|
|
51
89
|
const cols = layout.getGeo().cols;
|
|
52
90
|
const maxName = Math.max(...filtered.map((c) => displayWidth(c.name)));
|
|
53
91
|
return filtered.map((c, i) => {
|
|
54
|
-
|
|
92
|
+
// 选中项:▸ 与文字均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
|
|
93
|
+
const isSel = i === selected;
|
|
94
|
+
const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
|
|
95
|
+
const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
|
|
55
96
|
const name = padEndDisplay(c.name, maxName);
|
|
56
97
|
const descW = cols - maxName - 5; // marker + 空格 + 2 间距
|
|
57
98
|
const desc = descW > 0 ? truncateDisplay(c.desc, descW) : '';
|
|
58
|
-
return `${marker} ${
|
|
99
|
+
return `${marker} ${color}${name}${ui.reset} ${color}${desc}${ui.reset}`;
|
|
59
100
|
});
|
|
60
101
|
}
|
|
61
102
|
/** 当前光标在该行的显示列(供 layout 定位光标)。 */
|
|
62
103
|
function cursorCol() {
|
|
63
104
|
return displayWidth(lines[cl].slice(0, cc));
|
|
64
105
|
}
|
|
106
|
+
/** chip 预览前缀:整段扁平化(行界→空格,避免框内折行)取前 ~20 列,超长 truncateDisplay 自带 …;末尾空格与 suffix 分隔。 */
|
|
107
|
+
function chipPrefix() {
|
|
108
|
+
return chip ? `[${truncateDisplay(chip.split('\n').join(' '), 20)}] ` : '';
|
|
109
|
+
}
|
|
110
|
+
/** 供 layout 画的行:chip 存在时把前缀拼到第 0 行前(chip 原子显示,不可编辑;光标活在 suffix)。 */
|
|
111
|
+
function dispLines() {
|
|
112
|
+
if (!chip)
|
|
113
|
+
return lines;
|
|
114
|
+
const pre = chipPrefix();
|
|
115
|
+
return lines.length > 0 ? [pre + lines[0], ...lines.slice(1)] : [pre];
|
|
116
|
+
}
|
|
117
|
+
/** 供 layout 定位光标列:chip 在第 0 行时偏移 chipPrefix 宽度(suffix 光标始终在 chip 之后)。 */
|
|
118
|
+
function dispCursorCol() {
|
|
119
|
+
return chip && cl === 0 ? displayWidth(chipPrefix()) + cursorCol() : cursorCol();
|
|
120
|
+
}
|
|
121
|
+
/** 在光标处插入文本(含换行则拆行)。供短粘贴 finalize 落为可编辑文本。 */
|
|
122
|
+
function insertText(text) {
|
|
123
|
+
const parts = text.split('\n');
|
|
124
|
+
const before = lines[cl].slice(0, cc);
|
|
125
|
+
const after = lines[cl].slice(cc);
|
|
126
|
+
const newLines = [before + parts[0]];
|
|
127
|
+
for (let i = 1; i < parts.length; i++)
|
|
128
|
+
newLines.push(parts[i]);
|
|
129
|
+
newLines[newLines.length - 1] += after;
|
|
130
|
+
lines.splice(cl, 1, ...newLines);
|
|
131
|
+
cl = cl + parts.length - 1;
|
|
132
|
+
// 光标置于插入文本末尾 = 原 before + 末段长度(指向插入文本之后、after 之前)。
|
|
133
|
+
// 旧值 parts[...].length 漏算 before → 光标落到插入文本内部(行中间)→ 后续打字插到中间(bug)。
|
|
134
|
+
cc = before.length + parts[parts.length - 1].length;
|
|
135
|
+
}
|
|
136
|
+
/** 粘贴结束:长粘贴(>8 行或 >400 字符)落/并进 chip(原子,整段封预览),短粘贴落为可编辑文本。 */
|
|
137
|
+
function finalizePaste() {
|
|
138
|
+
if (resolved) {
|
|
139
|
+
pasteParts = [];
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const buf = pasteParts.join('');
|
|
143
|
+
pasteParts = [];
|
|
144
|
+
justSawCR = false;
|
|
145
|
+
if (!buf)
|
|
146
|
+
return;
|
|
147
|
+
const isLong = buf.split('\n').length > 8 || buf.length > 400;
|
|
148
|
+
if (isLong) {
|
|
149
|
+
chip = chip == null ? buf : chip + '\n' + buf; // 多块粘贴:并进同一 chip
|
|
150
|
+
lines = [''];
|
|
151
|
+
cl = 0;
|
|
152
|
+
cc = 0;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
insertText(buf);
|
|
156
|
+
}
|
|
157
|
+
computeFiltered();
|
|
158
|
+
redraw();
|
|
159
|
+
}
|
|
65
160
|
function computeFiltered() {
|
|
66
161
|
if (cl === 0 && lines[0].startsWith('/')) {
|
|
67
162
|
filtered = opts.commands.filter((c) => c.name.startsWith(lines[0]));
|
|
@@ -77,9 +172,9 @@ export async function promptWithSlashMenu(opts) {
|
|
|
77
172
|
function redraw() {
|
|
78
173
|
layout.paintInput({
|
|
79
174
|
prompt: opts.prompt,
|
|
80
|
-
lines,
|
|
175
|
+
lines: dispLines(),
|
|
81
176
|
cursorLine: cl,
|
|
82
|
-
cursorCol:
|
|
177
|
+
cursorCol: dispCursorCol(),
|
|
83
178
|
menu: menuLines().length ? { lines: menuLines() } : null,
|
|
84
179
|
});
|
|
85
180
|
}
|
|
@@ -91,6 +186,13 @@ export async function promptWithSlashMenu(opts) {
|
|
|
91
186
|
// 忽略
|
|
92
187
|
}
|
|
93
188
|
emitter.removeListener('keypress', onKey);
|
|
189
|
+
if (pasteTimer) {
|
|
190
|
+
clearTimeout(pasteTimer);
|
|
191
|
+
pasteTimer = null;
|
|
192
|
+
}
|
|
193
|
+
pasting = false;
|
|
194
|
+
pasteParts = [];
|
|
195
|
+
onPasteEnd = null;
|
|
94
196
|
stdin.pause();
|
|
95
197
|
}
|
|
96
198
|
function finish(value) {
|
|
@@ -100,14 +202,16 @@ export async function promptWithSlashMenu(opts) {
|
|
|
100
202
|
cleanup();
|
|
101
203
|
resolve(value);
|
|
102
204
|
}
|
|
103
|
-
/** 提交:菜单打开时先补全选中项到第 0 行。 */
|
|
205
|
+
/** 提交:菜单打开时先补全选中项到第 0 行。chip 与 suffix 拼回全文(chip 在前,换行接 suffix)。 */
|
|
104
206
|
function submit() {
|
|
105
207
|
if (menuOpen && filtered[selected]) {
|
|
106
208
|
lines = [filtered[selected].name];
|
|
107
209
|
cl = 0;
|
|
108
210
|
cc = lines[0].length;
|
|
109
211
|
}
|
|
110
|
-
|
|
212
|
+
const suffix = lines.join('\n');
|
|
213
|
+
const content = chip ? (suffix.length > 0 ? chip + '\n' + suffix : chip) : suffix;
|
|
214
|
+
finish(content === '' ? [''] : content.split('\n'));
|
|
111
215
|
}
|
|
112
216
|
/** 插换行:在光标处断行。 */
|
|
113
217
|
function insertNewline() {
|
|
@@ -119,9 +223,67 @@ export async function promptWithSlashMenu(opts) {
|
|
|
119
223
|
computeFiltered();
|
|
120
224
|
redraw();
|
|
121
225
|
}
|
|
226
|
+
/** 清空输入(含 chip / 斜杠菜单 / 粘贴缓冲):Ctrl+C 在有内容时调用——清空而非退出。 */
|
|
227
|
+
function clearInput() {
|
|
228
|
+
if (layout.isScrolled())
|
|
229
|
+
layout.resetScroll(); // 回尾(若滚动回看),再清空
|
|
230
|
+
lines = [''];
|
|
231
|
+
cl = 0;
|
|
232
|
+
cc = 0;
|
|
233
|
+
chip = null;
|
|
234
|
+
menuOpen = false;
|
|
235
|
+
filtered = [];
|
|
236
|
+
selected = 0;
|
|
237
|
+
if (pasteTimer) {
|
|
238
|
+
clearTimeout(pasteTimer);
|
|
239
|
+
pasteTimer = null;
|
|
240
|
+
}
|
|
241
|
+
pasting = false;
|
|
242
|
+
pasteParts = [];
|
|
243
|
+
justSawCR = false;
|
|
244
|
+
computeFiltered();
|
|
245
|
+
redraw();
|
|
246
|
+
}
|
|
247
|
+
/** Ctrl+C:有内容(已打字 / 多行 / chip / 菜单草稿 / 粘贴缓冲 / 粘贴中)则清空,再按一次(空)才退出(仿 fish / Claude Code)。 */
|
|
248
|
+
function onCtrlC() {
|
|
249
|
+
const hasContent = chip != null ||
|
|
250
|
+
lines.length > 1 ||
|
|
251
|
+
lines.some((l) => l !== '') ||
|
|
252
|
+
pasteParts.length > 0 ||
|
|
253
|
+
pasting;
|
|
254
|
+
if (hasContent) {
|
|
255
|
+
clearInput();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
cleanup();
|
|
259
|
+
reject(new Error('SIGINT'));
|
|
260
|
+
}
|
|
122
261
|
function onKey(_str, key) {
|
|
123
262
|
if (resolved || !key)
|
|
124
263
|
return;
|
|
264
|
+
// Ctrl+C:有内容则清空,再按一次(空)才退出——置顶,使粘贴中也能被截到(否则被 pasting 分支吞掉)
|
|
265
|
+
if (key.ctrl && key.name === 'c') {
|
|
266
|
+
onCtrlC();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// 粘贴中:把键累积进 pasteParts(换行 \r\n 合一),不编辑 lines;末尾 finalizePaste 统一落 chip/文本
|
|
270
|
+
if (pasting) {
|
|
271
|
+
const s = key.sequence ?? '';
|
|
272
|
+
const isReturn = key.name === 'return' || key.name === 'enter';
|
|
273
|
+
if (s === '\n' && justSawCR) {
|
|
274
|
+
justSawCR = false; // \r\n 的 \n:已随 \r 折行,吞掉
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (isReturn || s === '\r' || s === '\n') {
|
|
278
|
+
justSawCR = s === '\r'; // \r 标记,待可能的尾随 \n
|
|
279
|
+
pasteParts.push('\n');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
justSawCR = false;
|
|
283
|
+
if (s && s >= ' ' && !key.ctrl && !key.meta)
|
|
284
|
+
pasteParts.push(s);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
125
287
|
// 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑↓ 与 plain ↑/↓ 单行。
|
|
126
288
|
// plain ↑/↓ 仅在单行输入且菜单关闭时作滚动(多行编辑留给光标移动,菜单打开留给选项);
|
|
127
289
|
// 兼鼠标滚轮——WT alt 屏(经 \x1B[?1007h)滚轮转发 ↑/↓。
|
|
@@ -149,12 +311,7 @@ export async function promptWithSlashMenu(opts) {
|
|
|
149
311
|
// 其他键:若处于滚动回看,先回尾再处理(打字即回底)
|
|
150
312
|
if (layout.isScrolled())
|
|
151
313
|
layout.resetScroll();
|
|
152
|
-
//
|
|
153
|
-
if (key.ctrl && key.name === 'c') {
|
|
154
|
-
cleanup();
|
|
155
|
-
reject(new Error('SIGINT'));
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
314
|
+
// Ctrl+C 已在 onKey 顶部统一处理(清空或退出);Ctrl+D:空缓冲退出,否则忽略
|
|
158
315
|
if (key.ctrl && key.name === 'd') {
|
|
159
316
|
if (lines.every((l) => l === '') && cl === 0 && cc === 0)
|
|
160
317
|
finish(null);
|
|
@@ -171,17 +328,17 @@ export async function promptWithSlashMenu(opts) {
|
|
|
171
328
|
return;
|
|
172
329
|
}
|
|
173
330
|
const isReturn = key.name === 'return' || key.name === 'enter';
|
|
174
|
-
// 换行:Ctrl+J / Alt+Enter(meta)/ Shift+Enter(终端区分时)/
|
|
331
|
+
// 换行:Ctrl+J / Alt+Enter(meta)/ Shift+Enter(终端区分时)/ lone LF
|
|
332
|
+
// (粘贴的 CR/LF 已在上方 pasting 分支累积进 pasteParts,不会到此)
|
|
175
333
|
const wantNewline = (key.ctrl && key.name === 'j') ||
|
|
176
334
|
(key.meta && isReturn) ||
|
|
177
335
|
(key.shift && isReturn) ||
|
|
178
336
|
(key.sequence === '\n' && !key.ctrl);
|
|
179
|
-
// 换行(Ctrl+J / Alt+Enter / Shift+Enter / 粘贴 LF)
|
|
180
337
|
if (wantNewline) {
|
|
181
338
|
insertNewline();
|
|
182
339
|
return;
|
|
183
340
|
}
|
|
184
|
-
// 提交(plain Enter)
|
|
341
|
+
// 提交(键盘 plain Enter)
|
|
185
342
|
if (isReturn && !key.shift && !key.meta && !key.ctrl) {
|
|
186
343
|
submit();
|
|
187
344
|
return;
|
|
@@ -204,6 +361,12 @@ export async function promptWithSlashMenu(opts) {
|
|
|
204
361
|
computeFiltered();
|
|
205
362
|
redraw();
|
|
206
363
|
}
|
|
364
|
+
else if (chip) {
|
|
365
|
+
// 光标在 suffix 开头(紧贴 ] 后):退格删整个 chip(原子)
|
|
366
|
+
chip = null;
|
|
367
|
+
computeFiltered();
|
|
368
|
+
redraw();
|
|
369
|
+
}
|
|
207
370
|
return;
|
|
208
371
|
case 'up':
|
|
209
372
|
if (menuOpen && filtered.length) {
|
|
@@ -284,6 +447,8 @@ export async function promptWithSlashMenu(opts) {
|
|
|
284
447
|
return new Promise((res, rej) => {
|
|
285
448
|
resolve = res;
|
|
286
449
|
reject = rej;
|
|
450
|
+
ensurePasteDetector(); // 首次调用在 emitKeypressEvents 之前装 data 监听器(保序:mine 先于 解析器)
|
|
451
|
+
onPasteEnd = finalizePaste; // 粘贴结束回调:落 chip 或保留文本
|
|
287
452
|
readline.emitKeypressEvents(stdin);
|
|
288
453
|
let rawOk = true;
|
|
289
454
|
try {
|
|
@@ -330,10 +495,13 @@ export async function promptTurnPicker(items) {
|
|
|
330
495
|
const cols = g.cols;
|
|
331
496
|
return Array.from({ length: count }, (_, i) => {
|
|
332
497
|
const idx = start + i;
|
|
333
|
-
|
|
334
|
-
const
|
|
498
|
+
// 选中项:▸/序号/正文均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
|
|
499
|
+
const isSel = idx === selected;
|
|
500
|
+
const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
|
|
501
|
+
const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
|
|
502
|
+
const num = `${color}${idx + 1}${ui.reset}`;
|
|
335
503
|
const text = truncateDisplay(items[idx].firstLine, cols - 6);
|
|
336
|
-
return `${marker} ${num} ${
|
|
504
|
+
return `${marker} ${num} ${color}${text}${ui.reset}`;
|
|
337
505
|
});
|
|
338
506
|
}
|
|
339
507
|
function redraw() {
|
|
@@ -343,6 +511,7 @@ export async function promptTurnPicker(items) {
|
|
|
343
511
|
cursorLine: 0,
|
|
344
512
|
cursorCol: displayWidth(hint),
|
|
345
513
|
menu: { lines: menuLines() },
|
|
514
|
+
caret: false, // 纯导航菜单(非文本输入):不画输入框块状光标,聚焦由菜单 ▸ 标记
|
|
346
515
|
});
|
|
347
516
|
}
|
|
348
517
|
function cleanup() {
|
|
@@ -395,6 +564,154 @@ export async function promptTurnPicker(items) {
|
|
|
395
564
|
return new Promise((res, rej) => {
|
|
396
565
|
resolve = res;
|
|
397
566
|
reject = rej;
|
|
567
|
+
ensurePasteDetector();
|
|
568
|
+
readline.emitKeypressEvents(stdin);
|
|
569
|
+
let rawOk = true;
|
|
570
|
+
try {
|
|
571
|
+
stdin.setRawMode(true);
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
rawOk = false;
|
|
575
|
+
}
|
|
576
|
+
if (!rawOk) {
|
|
577
|
+
res(null);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
stdin.resume();
|
|
581
|
+
emitter.on('keypress', onKey);
|
|
582
|
+
redraw();
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
export async function promptSessionPicker(items, recentCap = 10) {
|
|
586
|
+
if (!layout.isActive() || items.length === 0)
|
|
587
|
+
return null;
|
|
588
|
+
const emitter = stdin;
|
|
589
|
+
const cap = Math.max(1, recentCap);
|
|
590
|
+
const canToggle = items.length > cap; // 不超过 cap 时无切换意义(本就全显)
|
|
591
|
+
let showAll = !canToggle; // 超过 cap 才默认折叠到最近 N,否则全显
|
|
592
|
+
let selected = 0; // 默认聚焦首项(调用方降序传入 → 最新会话)
|
|
593
|
+
let resolved = false;
|
|
594
|
+
let resolve;
|
|
595
|
+
let reject;
|
|
596
|
+
/** 当前可见项:折叠态取前 cap 条(=最近 N),展开态取全部。 */
|
|
597
|
+
function visible() {
|
|
598
|
+
return showAll ? items : items.slice(0, cap);
|
|
599
|
+
}
|
|
600
|
+
/** 输入框行的操作提示;折叠态显「a 全部(N)」,展开态显「a 仅最近N」。不超 cap 时无 a 项。 */
|
|
601
|
+
function hint() {
|
|
602
|
+
const base = '↑↓ 选择 · Enter 续接 · Esc 取消';
|
|
603
|
+
if (!canToggle)
|
|
604
|
+
return base;
|
|
605
|
+
return showAll
|
|
606
|
+
? `↑↓ 选择 · Enter 续接 · a 仅最近${cap} · Esc 取消`
|
|
607
|
+
: `↑↓ 选择 · Enter 续接 · a 全部(${items.length}) · Esc 取消`;
|
|
608
|
+
}
|
|
609
|
+
/** 菜单行(带开窗):超屏高时以 selected 为中心取窗,保光标可见。行格式:▸ N title subtitle。 */
|
|
610
|
+
function menuLines() {
|
|
611
|
+
const g = layout.getGeo();
|
|
612
|
+
const cols = g.cols;
|
|
613
|
+
const maxRows = Math.max(1, g.contentBottom);
|
|
614
|
+
const vis = visible();
|
|
615
|
+
let start = 0;
|
|
616
|
+
if (vis.length > maxRows) {
|
|
617
|
+
start = Math.max(0, Math.min(selected - Math.floor(maxRows / 2), vis.length - maxRows));
|
|
618
|
+
}
|
|
619
|
+
const count = Math.min(maxRows, vis.length);
|
|
620
|
+
return Array.from({ length: count }, (_, i) => {
|
|
621
|
+
const idx = start + i;
|
|
622
|
+
// 选中项:▸/序号/正文/副标题均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
|
|
623
|
+
const isSel = idx === selected;
|
|
624
|
+
const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
|
|
625
|
+
const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
|
|
626
|
+
const num = String(idx + 1);
|
|
627
|
+
const it = vis[idx];
|
|
628
|
+
const title = it.title || '(无)';
|
|
629
|
+
const sub = it.subtitle ?? '';
|
|
630
|
+
const leadW = displayWidth(num) + 4; // "▸ " + num + " "
|
|
631
|
+
let subW = sub ? displayWidth(sub) + 2 : 0; // " " + sub
|
|
632
|
+
let titleW = cols - leadW - subW;
|
|
633
|
+
if (titleW < 4 && sub) {
|
|
634
|
+
// 太窄:先丢副标题把空间让给标题
|
|
635
|
+
subW = 0;
|
|
636
|
+
titleW = cols - leadW;
|
|
637
|
+
}
|
|
638
|
+
const titleT = titleW > 0 ? truncateDisplay(title, titleW) : '';
|
|
639
|
+
const subPart = subW > 0 ? ` ${sub}` : '';
|
|
640
|
+
return `${marker} ${color}${num} ${titleT}${subPart}${ui.reset}`;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
function redraw() {
|
|
644
|
+
const h = hint();
|
|
645
|
+
layout.paintInput({
|
|
646
|
+
prompt: '❯ ',
|
|
647
|
+
lines: [h],
|
|
648
|
+
cursorLine: 0,
|
|
649
|
+
cursorCol: displayWidth(h),
|
|
650
|
+
menu: { lines: menuLines() },
|
|
651
|
+
caret: false, // 纯导航菜单(非文本输入):不画输入框块状光标,聚焦由菜单 ▸ 标记
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
function cleanup() {
|
|
655
|
+
try {
|
|
656
|
+
stdin.setRawMode(false);
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
// 忽略
|
|
660
|
+
}
|
|
661
|
+
emitter.removeListener('keypress', onKey);
|
|
662
|
+
stdin.pause();
|
|
663
|
+
}
|
|
664
|
+
function finish(value) {
|
|
665
|
+
if (resolved)
|
|
666
|
+
return;
|
|
667
|
+
resolved = true;
|
|
668
|
+
cleanup();
|
|
669
|
+
resolve(value);
|
|
670
|
+
}
|
|
671
|
+
function onKey(_str, key) {
|
|
672
|
+
if (resolved || !key)
|
|
673
|
+
return;
|
|
674
|
+
if (key.ctrl && key.name === 'c') {
|
|
675
|
+
cleanup();
|
|
676
|
+
reject(new Error('SIGINT'));
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (key.ctrl && key.name === 'd') {
|
|
680
|
+
finish(null);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
// a 切换「仅最近 N / 全部」:纯导航菜单无文本输入,a 自由;key.name 不分大小写,Shift+A 亦触发。
|
|
684
|
+
if (canToggle && key.name === 'a' && !key.ctrl && !key.meta) {
|
|
685
|
+
showAll = !showAll;
|
|
686
|
+
const vis = visible();
|
|
687
|
+
if (selected > vis.length - 1)
|
|
688
|
+
selected = vis.length - 1; // 折回 cap 时选中项越界则钳到末项
|
|
689
|
+
redraw();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const n = visible().length;
|
|
693
|
+
switch (key.name) {
|
|
694
|
+
case 'up':
|
|
695
|
+
selected = (selected - 1 + n) % n;
|
|
696
|
+
redraw();
|
|
697
|
+
return;
|
|
698
|
+
case 'down':
|
|
699
|
+
selected = (selected + 1) % n;
|
|
700
|
+
redraw();
|
|
701
|
+
return;
|
|
702
|
+
case 'return':
|
|
703
|
+
case 'enter':
|
|
704
|
+
finish(visible()[selected] ?? null);
|
|
705
|
+
return;
|
|
706
|
+
case 'escape':
|
|
707
|
+
finish(null);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return new Promise((res, rej) => {
|
|
712
|
+
resolve = res;
|
|
713
|
+
reject = rej;
|
|
714
|
+
ensurePasteDetector();
|
|
398
715
|
readline.emitKeypressEvents(stdin);
|
|
399
716
|
let rawOk = true;
|
|
400
717
|
try {
|
package/dist/ui/render.js
CHANGED
|
@@ -60,6 +60,24 @@ export function displayWidth(str) {
|
|
|
60
60
|
w += charWidth(ch.codePointAt(0) ?? 0);
|
|
61
61
|
return w;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* 毫秒 → Claude Code 式耗时串:运行中状态行走时与轮次结束摘要行共用。
|
|
65
|
+
* <10s 显 1 位小数(3.2s);10-59s 整数(12s);≥60s m+s(3m 3s);≥1h h+m(1h 2m)。
|
|
66
|
+
*/
|
|
67
|
+
export function fmtElapsed(ms) {
|
|
68
|
+
const s = ms / 1000;
|
|
69
|
+
if (s < 10)
|
|
70
|
+
return `${s.toFixed(1)}s`;
|
|
71
|
+
if (s < 60)
|
|
72
|
+
return `${Math.round(s)}s`;
|
|
73
|
+
const m = Math.floor(s / 60);
|
|
74
|
+
const rs = Math.round(s % 60);
|
|
75
|
+
if (m < 60)
|
|
76
|
+
return `${m}m ${rs}s`;
|
|
77
|
+
const h = Math.floor(m / 60);
|
|
78
|
+
const rm = m % 60;
|
|
79
|
+
return `${h}h ${rm}m`;
|
|
80
|
+
}
|
|
63
81
|
/** 去除 SGR 颜色转义(\x1B[…m),用于度量带色串的真实可见宽度。 */
|
|
64
82
|
export function stripAnsi(s) {
|
|
65
83
|
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
package/dist/ui/spinner.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { stdout } from 'node:process';
|
|
2
2
|
import { ui } from './theme.js';
|
|
3
3
|
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
4
|
+
/**
|
|
5
|
+
* 当前活跃 spinner:`start()` 的 TTY 路径登记,`stop()` 不清——供 `pauseCurrent()` 在介入面板
|
|
6
|
+
* (ask_human)进入前停转,避免 onFrame 的 `paintLiveAtCursor` 每 80ms 覆盖面板。同进程同时刻至多一个在转;
|
|
7
|
+
* 下次 `start()` 自动替换引用,无泄漏。非 TTY 路径不登记(无 onFrame,无需暂停)。
|
|
8
|
+
*/
|
|
9
|
+
let activeSpinner = null;
|
|
4
10
|
/**
|
|
5
11
|
* 等待动画:在 await 长操作(chat / 工具执行 / 压缩)时旋转,避免「卡死」错觉。
|
|
6
12
|
*
|
|
@@ -26,6 +32,7 @@ export class Spinner {
|
|
|
26
32
|
stdout.write(`${ui.dim}${msg}…${ui.reset}\n`);
|
|
27
33
|
return;
|
|
28
34
|
}
|
|
35
|
+
activeSpinner = this; // TTY 路径:登记为当前可暂停 spinner(介入面板进入前 pauseCurrent 停转)
|
|
29
36
|
if (this.onFrame) {
|
|
30
37
|
const cb = this.onFrame;
|
|
31
38
|
this.frame = 0;
|
|
@@ -60,4 +67,12 @@ export class Spinner {
|
|
|
60
67
|
stdout.write('\r\x1B[K');
|
|
61
68
|
}
|
|
62
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* 暂停当前活跃 spinner(供 ask_human 介入面板进入前停转,避免 onFrame 覆盖面板)。
|
|
72
|
+
* 未转则 no-op(stop 内 `!this.timer` 守护);停转时 onFrame(msg,null) 触发 clearLiveAtCursor 清续写位帧。
|
|
73
|
+
* 不 resume——ask_human 的活儿就是面板本身,退出后 agent 的 spinner.stop() 是 no-op,下一轮 start 自然重启。
|
|
74
|
+
*/
|
|
75
|
+
static pauseCurrent() {
|
|
76
|
+
activeSpinner?.stop();
|
|
77
|
+
}
|
|
63
78
|
}
|
package/dist/ui/theme.js
CHANGED
|
@@ -10,6 +10,7 @@ export const ui = {
|
|
|
10
10
|
reset: wrap('\x1B[0m'),
|
|
11
11
|
bold: wrap('\x1B[1m'),
|
|
12
12
|
dim: wrap('\x1B[2m'),
|
|
13
|
+
reverse: wrap('\x1B[7m'), // 反白(fg/bg 互换):块状输入光标用,光标处字符整格反白
|
|
13
14
|
red: wrap('\x1B[31m'),
|
|
14
15
|
green: wrap('\x1B[32m'),
|
|
15
16
|
yellow: wrap('\x1B[33m'),
|
|
@@ -19,4 +20,6 @@ export const ui = {
|
|
|
19
20
|
magenta: wrap('\x1B[35m'),
|
|
20
21
|
brightCyan: wrap('\x1B[96m'),
|
|
21
22
|
brightMagenta: wrap('\x1B[95m'),
|
|
23
|
+
/** 用户消息满宽背景色(上滑时易辨认);bright black bg = 深灰,深色终端上微妙可辨。 */
|
|
24
|
+
userBg: wrap('\x1B[100m'),
|
|
22
25
|
};
|