@bolloon/bolloon-agent 0.3.31 → 0.3.32
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/dist/cli/ink-app.js +345 -2
- package/dist/cli/mention-data.js +238 -0
- package/dist/index.js +7 -4
- package/package.json +1 -1
package/dist/cli/ink-app.js
CHANGED
|
@@ -3,11 +3,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
* ink-app.tsx — Ink (React for CLI) 渲染入口
|
|
4
4
|
*
|
|
5
5
|
* 用 Yoga flexbox 布局实现: 内容置顶, 输入栏固定底部, 状态栏固定
|
|
6
|
+
*
|
|
7
|
+
* 2026-08-05: @ / # 弹出选择窗 — 输入 @ 命中智能体, / 命中命令+技能+插件, # 命中文件
|
|
8
|
+
* ↑/↓ 导航, Tab/Enter 选中, Esc 关闭, 弹出窗打开时 TextInput 让出焦点 (focus=false)
|
|
6
9
|
*/
|
|
7
|
-
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
10
|
+
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
8
11
|
import { render, Box, Text, useInput, useApp } from 'ink';
|
|
9
12
|
import TextInput from 'ink-text-input';
|
|
10
13
|
import { brandArtLines, boxTop, boxRow, boxBottom, dispWidth } from './loading-tui.js';
|
|
14
|
+
import { loadAgents, loadCommands, loadSkills, loadPlugins, loadFiles, getMention, matchFileScore, } from './mention-data.js';
|
|
11
15
|
// ─── 组件: Logo Box ──────────────────────────────────────────────────────────
|
|
12
16
|
const LogoBox = ({ width }) => {
|
|
13
17
|
const art = brandArtLines();
|
|
@@ -24,6 +28,17 @@ const Messages = ({ msgs }) => (_jsx(Box, { flexDirection: "column", flexGrow: 1
|
|
|
24
28
|
const clean = m.replace(/\x1b\[[0-9;]*m/g, '');
|
|
25
29
|
return clean.trim() ? _jsx(Text, { children: clean ? m : '' }, i) : null;
|
|
26
30
|
}) }));
|
|
31
|
+
const MentionPopup = ({ title, items, sel, width, loading }) => {
|
|
32
|
+
const MAX_ROWS = 8;
|
|
33
|
+
const shown = items.slice(0, MAX_ROWS);
|
|
34
|
+
const innerW = Math.max(width - 2, 10);
|
|
35
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Text, { color: "cyan", bold: true, children: `╭─ ${title} ${'─'.repeat(Math.max(2, innerW - dispWidth(title) - 4))}╮` }), loading && items.length === 0 ? (_jsx(Text, { color: "dim", children: "\u2502 \u626B\u63CF\u4E2D..." })) : !loading && items.length === 0 ? (_jsx(Text, { color: "dim", children: "\u2502 \u65E0\u5339\u914D" })) : (shown.map((it, i) => {
|
|
36
|
+
const active = i === sel;
|
|
37
|
+
const label = it.kind === 'file' ? it.label : `${it.kind === 'skill' ? '⚡' : it.kind === 'plugin' ? '🔌' : ''}${it.label}`;
|
|
38
|
+
const hint = it.hint ? `${it.hint}` : it.kind === 'file' ? '文件' : '';
|
|
39
|
+
return (_jsxs(Box, { width: innerW, children: [_jsx(Text, { color: active ? 'black' : undefined, backgroundColor: active ? 'cyan' : undefined, children: `${active ? '❯ ' : ' '}${label}` }), _jsx(Text, { color: active ? 'black' : 'dim', backgroundColor: active ? 'cyan' : undefined, dimColor: !active, children: ` ${hint}` })] }, `${it.kind}:${it.label}`));
|
|
40
|
+
})), items.length > MAX_ROWS && (_jsxs(Text, { color: "dim", children: ["\u2502 \u8FD8\u6709 ", items.length - MAX_ROWS, " \u9879..."] })), _jsx(Text, { color: "cyan", children: `╰${'─'.repeat(innerW)}╯` })] }));
|
|
41
|
+
};
|
|
27
42
|
const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH }) => {
|
|
28
43
|
const [input, setInput] = useState('');
|
|
29
44
|
const [msgs, setMsgs] = useState([]);
|
|
@@ -34,6 +49,206 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
34
49
|
// 双击 Esc 退出当前进程 (500ms 窗口内第二次按下)
|
|
35
50
|
const lastEscRef = useRef(0);
|
|
36
51
|
const C_WARN_ANSI = '\x1b[38;2;245;158;11m'; // #f59e0b
|
|
52
|
+
// ── @ / # 弹出窗状态 ──────────────────────────────────────────────────────
|
|
53
|
+
const mention = useMemo(() => getMention(input), [input]);
|
|
54
|
+
const mentionKey = mention ? `${mention.kind}:${mention.start}` : null;
|
|
55
|
+
const [items, setItems] = useState([]);
|
|
56
|
+
const [sel, setSel] = useState(0);
|
|
57
|
+
const [dismissed, setDismissed] = useState(null);
|
|
58
|
+
const [loadingFiles, setLoadingFiles] = useState(false);
|
|
59
|
+
// Tab 补齐弹窗 (非 @ / # 触发的普通 token 补齐): { start, items }
|
|
60
|
+
const [tabState, setTabState] = useState(null);
|
|
61
|
+
const agentCache = useRef(null);
|
|
62
|
+
const skillCache = useRef(null);
|
|
63
|
+
const pluginCache = useRef(null);
|
|
64
|
+
const fileCache = useRef(null);
|
|
65
|
+
// ── 输入历史 (↑/↓ 切换) ───────────────────────────────────────────────────
|
|
66
|
+
const historyRef = useRef([]);
|
|
67
|
+
const historyIdxRef = useRef(-1); // -1 = 正在编辑新草稿
|
|
68
|
+
const draftRef = useRef('');
|
|
69
|
+
// 加载当前 mention 的候选 (agent/command 缓存一次; file 每次打开重扫)
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (tabState) {
|
|
72
|
+
setItems(tabState.items);
|
|
73
|
+
setSel(0);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!mention || !mentionKey) {
|
|
77
|
+
setItems([]);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (dismissed === mentionKey) {
|
|
81
|
+
setItems([]);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
let cancelled = false;
|
|
85
|
+
if (mention.kind === 'agent') {
|
|
86
|
+
if (agentCache.current) {
|
|
87
|
+
setItems(agentCache.current);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
loadAgents()
|
|
91
|
+
.then(list => { agentCache.current = list; if (!cancelled)
|
|
92
|
+
setItems(list); })
|
|
93
|
+
.catch(() => { if (!cancelled)
|
|
94
|
+
setItems([]); });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (mention.kind === 'command') {
|
|
98
|
+
// 命令立即显示, 技能/插件异步合并
|
|
99
|
+
const base = loadCommands();
|
|
100
|
+
setItems(base);
|
|
101
|
+
const skillsP = skillCache.current
|
|
102
|
+
? Promise.resolve(skillCache.current)
|
|
103
|
+
: loadSkills().then(s => { skillCache.current = s; return s; });
|
|
104
|
+
const pluginsP = pluginCache.current
|
|
105
|
+
? Promise.resolve(pluginCache.current)
|
|
106
|
+
: loadPlugins().then(p => { pluginCache.current = p; return p; });
|
|
107
|
+
Promise.all([skillsP, pluginsP])
|
|
108
|
+
.then(([sk, pl]) => {
|
|
109
|
+
if (cancelled)
|
|
110
|
+
return;
|
|
111
|
+
const merged = [...base];
|
|
112
|
+
for (const it of [...sk, ...pl]) {
|
|
113
|
+
if (!merged.some(m => m.kind === it.kind && m.label === it.label))
|
|
114
|
+
merged.push(it);
|
|
115
|
+
}
|
|
116
|
+
setItems(merged);
|
|
117
|
+
})
|
|
118
|
+
.catch(() => { });
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
// file: 每次打开重扫 (cwd 可能变化)
|
|
122
|
+
setLoadingFiles(true);
|
|
123
|
+
loadFiles(mention.query)
|
|
124
|
+
.then(list => { if (!cancelled) {
|
|
125
|
+
fileCache.current = list;
|
|
126
|
+
setItems(list);
|
|
127
|
+
setLoadingFiles(false);
|
|
128
|
+
} })
|
|
129
|
+
.catch(() => { if (!cancelled) {
|
|
130
|
+
setItems([]);
|
|
131
|
+
setLoadingFiles(false);
|
|
132
|
+
} });
|
|
133
|
+
}
|
|
134
|
+
setSel(0);
|
|
135
|
+
return () => { cancelled = true; };
|
|
136
|
+
}, [mentionKey, dismissed, tabState]);
|
|
137
|
+
// 按查询过滤 + 排序
|
|
138
|
+
const filtered = useMemo(() => {
|
|
139
|
+
if (tabState)
|
|
140
|
+
return items; // Tab 补齐: 已按前缀过滤好
|
|
141
|
+
if (!mention)
|
|
142
|
+
return [];
|
|
143
|
+
const q = mention.query.toLowerCase();
|
|
144
|
+
if (!q)
|
|
145
|
+
return items;
|
|
146
|
+
if (mention.kind === 'file') {
|
|
147
|
+
return items
|
|
148
|
+
.filter(it => matchFileScore(it.label.toLowerCase(), q) >= 0)
|
|
149
|
+
.sort((a, b) => matchFileScore(a.label.toLowerCase(), q) - matchFileScore(b.label.toLowerCase(), q));
|
|
150
|
+
}
|
|
151
|
+
return items.filter(it => it.label.toLowerCase().includes(q));
|
|
152
|
+
}, [items, mention, tabState]);
|
|
153
|
+
const popupOpen = !!(tabState || (mention && dismissed !== mentionKey));
|
|
154
|
+
const safeSel = Math.min(sel, Math.max(0, filtered.length - 1));
|
|
155
|
+
const popupTitle = tabState ? 'Tab 补齐'
|
|
156
|
+
: mention?.kind === 'agent' ? '@ 智能体'
|
|
157
|
+
: mention?.kind === 'file' ? '# 文件'
|
|
158
|
+
: '/ 命令 · 技能 · 插件';
|
|
159
|
+
// 在指定 start 位置插入补齐文本 (函数式更新, 闭包安全)
|
|
160
|
+
const insertAt = useCallback((start, it) => {
|
|
161
|
+
setInput(cur => {
|
|
162
|
+
if (start > cur.length)
|
|
163
|
+
return cur;
|
|
164
|
+
let insertText;
|
|
165
|
+
if (it.kind === 'agent')
|
|
166
|
+
insertText = '@' + it.insert + ' ';
|
|
167
|
+
else if (it.kind === 'file')
|
|
168
|
+
insertText = '#' + it.insert + ' ';
|
|
169
|
+
else if (it.kind === 'skill')
|
|
170
|
+
insertText = 'use_skill ' + it.insert + ' ';
|
|
171
|
+
else
|
|
172
|
+
insertText = '/' + it.insert + ' ';
|
|
173
|
+
return cur.slice(0, start) + insertText;
|
|
174
|
+
});
|
|
175
|
+
// TextInput 内部 cursorOffset 在值被重写后不重置 (2026-08-05 实测),
|
|
176
|
+
// 插入后强制重挂载让光标回到末尾; 仅此一处重挂载, 避免输入丢失窗口
|
|
177
|
+
setTiKey(k => k + 1);
|
|
178
|
+
}, []);
|
|
179
|
+
// 接受当前选中项 → 替换 token 插入输入
|
|
180
|
+
// 函数式更新 + 从最新 state 重新推导 mention (useInput 闭包可能陈旧, 2026-08-05)
|
|
181
|
+
const acceptMention = useCallback((it) => {
|
|
182
|
+
if (tabState) {
|
|
183
|
+
insertAt(tabState.start, it);
|
|
184
|
+
setTabState(null);
|
|
185
|
+
setDismissed(null);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
setInput(cur => {
|
|
189
|
+
const m = getMention(cur);
|
|
190
|
+
if (!m)
|
|
191
|
+
return cur;
|
|
192
|
+
let insertText;
|
|
193
|
+
if (it.kind === 'agent')
|
|
194
|
+
insertText = '@' + it.insert + ' ';
|
|
195
|
+
else if (it.kind === 'file')
|
|
196
|
+
insertText = '#' + it.insert + ' ';
|
|
197
|
+
else if (it.kind === 'skill')
|
|
198
|
+
insertText = 'use_skill ' + it.insert + ' ';
|
|
199
|
+
else
|
|
200
|
+
insertText = '/' + it.insert + ' ';
|
|
201
|
+
return cur.slice(0, m.start) + insertText;
|
|
202
|
+
});
|
|
203
|
+
setTiKey(k => k + 1);
|
|
204
|
+
setDismissed(null);
|
|
205
|
+
}, [tabState, insertAt]);
|
|
206
|
+
// Tab 命令补齐: 无触发符的普通 token 也补 (命令/技能/插件/智能体/文件)
|
|
207
|
+
const doTabCompletion = useCallback(() => {
|
|
208
|
+
const m = input.match(/(^|\s)([^\s]*)$/);
|
|
209
|
+
if (!m)
|
|
210
|
+
return;
|
|
211
|
+
const [, pre, token] = m;
|
|
212
|
+
const start = (m.index || 0) + pre.length;
|
|
213
|
+
const q = token.toLowerCase();
|
|
214
|
+
const items = [];
|
|
215
|
+
const add = (list) => {
|
|
216
|
+
for (const it of list) {
|
|
217
|
+
if (items.some(x => x.kind === it.kind && x.label === it.label))
|
|
218
|
+
continue;
|
|
219
|
+
if (it.label.toLowerCase().startsWith(q))
|
|
220
|
+
items.push(it);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
if (q) {
|
|
224
|
+
add(loadCommands());
|
|
225
|
+
if (skillCache.current)
|
|
226
|
+
add(skillCache.current);
|
|
227
|
+
if (pluginCache.current)
|
|
228
|
+
add(pluginCache.current);
|
|
229
|
+
if (agentCache.current)
|
|
230
|
+
add(agentCache.current);
|
|
231
|
+
if (fileCache.current)
|
|
232
|
+
add(fileCache.current);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
// 空 token: 命令 + 技能 + 插件
|
|
236
|
+
add(loadCommands());
|
|
237
|
+
if (skillCache.current)
|
|
238
|
+
add(skillCache.current);
|
|
239
|
+
if (pluginCache.current)
|
|
240
|
+
add(pluginCache.current);
|
|
241
|
+
}
|
|
242
|
+
if (items.length === 1) {
|
|
243
|
+
insertAt(start, items[0]);
|
|
244
|
+
setTabState(null);
|
|
245
|
+
}
|
|
246
|
+
else if (items.length > 1) {
|
|
247
|
+
setTabState({ start, items });
|
|
248
|
+
setSel(0);
|
|
249
|
+
}
|
|
250
|
+
}, [input, insertAt]);
|
|
251
|
+
const [tiKey, setTiKey] = useState(0);
|
|
37
252
|
// 全局: 思考动画控制
|
|
38
253
|
useEffect(() => {
|
|
39
254
|
globalThis.__inkSetThinking = (v) => setThinking(v);
|
|
@@ -53,6 +268,14 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
53
268
|
const trimmed = value.trim();
|
|
54
269
|
if (!trimmed)
|
|
55
270
|
return;
|
|
271
|
+
// 入历史 (去重最近一条, 上限 100)
|
|
272
|
+
const hist = historyRef.current;
|
|
273
|
+
if (hist[hist.length - 1] !== trimmed)
|
|
274
|
+
hist.push(trimmed);
|
|
275
|
+
if (hist.length > 100)
|
|
276
|
+
hist.shift();
|
|
277
|
+
historyIdxRef.current = -1;
|
|
278
|
+
draftRef.current = '';
|
|
56
279
|
setInput('');
|
|
57
280
|
// 用户消息由 processInput 统一通过 appendLine(renderUserMessage) 显示
|
|
58
281
|
onPrompt(trimmed);
|
|
@@ -69,6 +292,97 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
69
292
|
requestExit();
|
|
70
293
|
return;
|
|
71
294
|
}
|
|
295
|
+
// ── 弹出窗打开: 全键接管 (TextInput focus=false 不处理) ──
|
|
296
|
+
if (popupOpen) {
|
|
297
|
+
if (key.upArrow) {
|
|
298
|
+
setSel(s => Math.max(0, s - 1));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (key.downArrow) {
|
|
302
|
+
setSel(s => Math.min(filtered.length - 1, s + 1));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if ((key.tab || key.return) && filtered.length > 0) {
|
|
306
|
+
const it = filtered[safeSel];
|
|
307
|
+
if (it)
|
|
308
|
+
acceptMention(it);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (key.escape) {
|
|
312
|
+
if (tabState)
|
|
313
|
+
setTabState(null);
|
|
314
|
+
else
|
|
315
|
+
setDismissed(mentionKey);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (key.backspace || key.delete) {
|
|
319
|
+
setInput(cur => cur.slice(0, -1));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// 粘贴/连发 chunk: Ink 把一次 stdin read 当单个 keypress (2026-08-05 实测)
|
|
323
|
+
// ① 连续退格 (\x7f×N) → 删 N 个字符
|
|
324
|
+
// ② 混合 chunk (退格+控制符, 含 ESC 序列) → 退格部分生效, ESC 序列忽略
|
|
325
|
+
// ③ 可打印 chunk (CJK/粘贴) → 整串追加
|
|
326
|
+
// 全部用函数式更新 — useInput 闭包可能陈旧 (实测), 函数式取最新 state
|
|
327
|
+
if (/^\x7f+$/.test(_input)) {
|
|
328
|
+
setInput(cur => cur.slice(0, Math.max(0, cur.length - _input.length)));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (/[\x00-\x1f\x7f]/.test(_input)) {
|
|
332
|
+
// 混合 chunk (退格+可打印): 逐字符处理; 含 ESC 序列 → 忽略整块 (箭头等由 TextInput 处理)
|
|
333
|
+
if (_input.includes('\u001b'))
|
|
334
|
+
return;
|
|
335
|
+
setInput(cur => {
|
|
336
|
+
let out = cur;
|
|
337
|
+
for (const ch of _input) {
|
|
338
|
+
if (ch === '\x7f')
|
|
339
|
+
out = out.slice(0, -1);
|
|
340
|
+
else if (/[\x00-\x1f]/.test(ch))
|
|
341
|
+
continue;
|
|
342
|
+
else
|
|
343
|
+
out += ch;
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
});
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (_input && !key.ctrl && !key.meta) {
|
|
350
|
+
setInput(cur => cur + _input);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
return; // 其余键忽略
|
|
354
|
+
}
|
|
355
|
+
// ── 正常模式 ──
|
|
356
|
+
// Tab 命令补齐 (无触发符的普通 token 也补)
|
|
357
|
+
if (key.tab) {
|
|
358
|
+
doTabCompletion();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// ↑/↓ 切换输入历史 (TextInput 本身忽略 up/down, 无冲突)
|
|
362
|
+
if (key.upArrow) {
|
|
363
|
+
const hist = historyRef.current;
|
|
364
|
+
if (hist.length === 0)
|
|
365
|
+
return;
|
|
366
|
+
if (historyIdxRef.current === -1)
|
|
367
|
+
draftRef.current = input;
|
|
368
|
+
if (historyIdxRef.current < hist.length - 1) {
|
|
369
|
+
historyIdxRef.current += 1;
|
|
370
|
+
setInput(hist[hist.length - 1 - historyIdxRef.current]);
|
|
371
|
+
setTiKey(k => k + 1);
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (key.downArrow) {
|
|
376
|
+
if (historyIdxRef.current === -1)
|
|
377
|
+
return;
|
|
378
|
+
historyIdxRef.current -= 1;
|
|
379
|
+
if (historyIdxRef.current === -1)
|
|
380
|
+
setInput(draftRef.current);
|
|
381
|
+
else
|
|
382
|
+
setInput(historyRef.current[historyRef.current.length - 1 - historyIdxRef.current]);
|
|
383
|
+
setTiKey(k => k + 1);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
72
386
|
// 双击 Esc 退出当前进程: 第一击提示, 500ms 内第二击退出
|
|
73
387
|
if (key.escape) {
|
|
74
388
|
const now = Date.now();
|
|
@@ -80,6 +394,21 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
80
394
|
inkAppendLine(`${C_WARN_ANSI}⚠ 再按一次 Esc 退出当前进程\x1b[0m`);
|
|
81
395
|
}
|
|
82
396
|
}
|
|
397
|
+
// 防御: 控制字符 chunk (TextInput 会把整 chunk 当字符追加, 2026-08-05 实测)
|
|
398
|
+
// setTimeout(0) 保证我们的纠正落在 TextInput 的 onChange 之后 (无论监听器顺序)
|
|
399
|
+
// \x7f×N → 先剥掉 TextInput 追加的垃圾, 再删 N 个真实字符
|
|
400
|
+
if (/^\x7f+$/.test(_input)) {
|
|
401
|
+
const n = _input.length;
|
|
402
|
+
setTimeout(() => setInput(cur => {
|
|
403
|
+
const cleaned = cur.replace(/[\x00-\x1f\x7f]+$/, '');
|
|
404
|
+
return cleaned.slice(0, Math.max(0, cleaned.length - n));
|
|
405
|
+
}), 0);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (/[\x00-\x1f\x7f]/.test(_input)) {
|
|
409
|
+
setTimeout(() => setInput(cur => cur.replace(/[\x00-\x1f\x7f]+$/, '')), 0);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
83
412
|
// TextInput handles actual input; useInput only for Ctrl+C / Esc
|
|
84
413
|
});
|
|
85
414
|
// 自动更新状态栏 (每秒)
|
|
@@ -91,6 +420,19 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
91
420
|
}, 1000);
|
|
92
421
|
return () => clearInterval(timer);
|
|
93
422
|
}, [getStatusUpdate]);
|
|
423
|
+
// 调试/测试钩子: 输入变化时通知外部 (pty 测试用)
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
globalThis.__inkOnInput?.(input);
|
|
426
|
+
}, [input]);
|
|
427
|
+
// 调试/测试钩子: 弹出窗状态 (pty 测试用)
|
|
428
|
+
useEffect(() => {
|
|
429
|
+
globalThis.__inkOnPopup?.({
|
|
430
|
+
open: popupOpen,
|
|
431
|
+
key: mentionKey,
|
|
432
|
+
count: filtered.length,
|
|
433
|
+
items: filtered.slice(0, 5).map(i => i.kind + ':' + i.label),
|
|
434
|
+
});
|
|
435
|
+
}, [popupOpen, mentionKey, filtered]);
|
|
94
436
|
// 思考动画 — kaomoji 旋转
|
|
95
437
|
const KAOMOJI = ['(`・ω・´)', '(´・_・`)', '(。•́︿•̀。)', 'ᕙ(▀̿̿Ĺ̯̿̿▀̿ ̿)ᕗ', '(◕‿◕)'];
|
|
96
438
|
useEffect(() => {
|
|
@@ -101,8 +443,9 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
101
443
|
}, 600);
|
|
102
444
|
return () => clearInterval(timer);
|
|
103
445
|
}, [thinking]);
|
|
104
|
-
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", children: [_jsx(LogoBox, { width: terminalW }), _jsx(Messages, { msgs: msgs }), thinking && (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] }) }))] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), _jsx(Box, { children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: "\u8F93\u5165\u6D88\u606F...
|
|
446
|
+
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", children: [_jsx(LogoBox, { width: terminalW }), _jsx(Messages, { msgs: msgs }), thinking && (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] }) }))] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), _jsx(Box, { children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), popupOpen && (tabState || mention) && (_jsx(MentionPopup, { title: popupTitle, items: filtered, sel: safeSel, width: terminalW, loading: loadingFiles })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, focus: !popupOpen, placeholder: "\u8F93\u5165\u6D88\u606F... @\u667A\u80FD\u4F53 /\u547D\u4EE4 #\u6587\u4EF6 \u00B7 Esc \u53CC\u51FB\u9000\u51FA \u00B7 /queue \u6392\u961F \u00B7 !\u7EC8\u7AEF\u547D\u4EE4" }, tiKey)] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) })] }));
|
|
105
447
|
};
|
|
448
|
+
export { InkApp };
|
|
106
449
|
// ─── 启动 ────────────────────────────────────────────────────────────────────
|
|
107
450
|
let _inkInstance = null;
|
|
108
451
|
export function startInk(onPrompt, initialStatus, getStatusUpdate) {
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mention-data.ts — CLI @ / # 弹出窗数据源 (2026-08-05)
|
|
3
|
+
*
|
|
4
|
+
* 弹出窗三路命中:
|
|
5
|
+
* @ → 智能体 (本地 channels + 远端 channel 缓存)
|
|
6
|
+
* / → 命令 (CLI 内置 + Web 斜杠命令) + 技能 (3 个 skill 目录) + 插件 (MCP servers)
|
|
7
|
+
* # → 文件 (cwd 有限深度遍历)
|
|
8
|
+
*
|
|
9
|
+
* 全部走本地文件读取, 不依赖 web server 是否在跑 (CLI 模式 server 不启动).
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'fs/promises';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
const HOME = () => process.env.HOME || os.homedir() || '/tmp';
|
|
15
|
+
// ─── 命令 (CLI 内置) ─────────────────────────────────────────────────────────
|
|
16
|
+
const CLI_COMMANDS = [
|
|
17
|
+
{ kind: 'command', label: 'queue', hint: '切换队列模式', insert: 'queue' },
|
|
18
|
+
{ kind: 'command', label: 'dequeue', hint: '出队一条', insert: 'dequeue' },
|
|
19
|
+
{ kind: 'command', label: 'help', hint: '显示帮助', insert: 'help' },
|
|
20
|
+
{ kind: 'command', label: 'exit', hint: '退出', insert: 'exit' },
|
|
21
|
+
{ kind: 'command', label: 'peers', hint: '查看 P2P 节点', insert: 'peers' },
|
|
22
|
+
{ kind: 'command', label: 'iroh', hint: '查看 iroh 状态', insert: 'iroh' },
|
|
23
|
+
{ kind: 'command', label: 'add_friend', hint: '添加好友 <64位hex公钥>', insert: 'add_friend' },
|
|
24
|
+
];
|
|
25
|
+
/** Web 端斜杠命令 (server /message 路由 → LLM 工具) */
|
|
26
|
+
const WEB_COMMANDS = [
|
|
27
|
+
{ kind: 'command', label: 'plan', hint: '创建计划', insert: 'plan' },
|
|
28
|
+
{ kind: 'command', label: 'todo', hint: '勾选步骤', insert: 'todo' },
|
|
29
|
+
{ kind: 'command', label: 'review', hint: '审查计划', insert: 'review' },
|
|
30
|
+
{ kind: 'command', label: 'task', hint: '创建任务', insert: 'task' },
|
|
31
|
+
{ kind: 'command', label: 'goal', hint: '暂停目标', insert: 'goal' },
|
|
32
|
+
{ kind: 'command', label: 'skill', hint: '沉淀技能', insert: 'skill' },
|
|
33
|
+
{ kind: 'command', label: 'add-friend', hint: '添加好友 (智能体工具)', insert: 'add-friend' },
|
|
34
|
+
];
|
|
35
|
+
export function loadCommands() {
|
|
36
|
+
return [...CLI_COMMANDS, ...WEB_COMMANDS];
|
|
37
|
+
}
|
|
38
|
+
// ─── 智能体 (@) ──────────────────────────────────────────────────────────────
|
|
39
|
+
/** 本地 channels.json (server-types.ts CHANNELS_PATH) + 远端 channel 缓存 */
|
|
40
|
+
export async function loadAgents() {
|
|
41
|
+
const out = [];
|
|
42
|
+
// 本地 channels: 优先 ~/.bolloon/sessions/channels.json, 兜底 ~/.bolloon/channels.json
|
|
43
|
+
const channelsPaths = [
|
|
44
|
+
path.join(HOME(), '.bolloon', 'sessions', 'channels.json'),
|
|
45
|
+
path.join(HOME(), '.bolloon', 'channels.json'),
|
|
46
|
+
];
|
|
47
|
+
for (const p of channelsPaths) {
|
|
48
|
+
try {
|
|
49
|
+
const chs = JSON.parse(await fs.readFile(p, 'utf-8'));
|
|
50
|
+
if (Array.isArray(chs)) {
|
|
51
|
+
for (const c of chs) {
|
|
52
|
+
const name = c?.name;
|
|
53
|
+
if (typeof name === 'string' && name.trim()) {
|
|
54
|
+
out.push({ kind: 'agent', label: name.trim(), hint: '本地智能体', insert: name.trim() });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch { /* 尝试下一个路径 */ }
|
|
61
|
+
}
|
|
62
|
+
// 远端 channel 缓存: { peerPublicKey: Channel[] }
|
|
63
|
+
try {
|
|
64
|
+
const remote = JSON.parse(await fs.readFile(path.join(HOME(), '.bolloon', 'remote-channels-cache.json'), 'utf-8'));
|
|
65
|
+
if (remote && typeof remote === 'object') {
|
|
66
|
+
for (const [peerPk, channels] of Object.entries(remote)) {
|
|
67
|
+
if (!Array.isArray(channels))
|
|
68
|
+
continue;
|
|
69
|
+
for (const c of channels) {
|
|
70
|
+
const name = c?.name;
|
|
71
|
+
if (typeof name === 'string' && name.trim()) {
|
|
72
|
+
out.push({ kind: 'agent', label: name.trim(), hint: `远端 · ${peerPk.slice(0, 8)}…`, insert: name.trim() });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch { /* 无远端缓存 */ }
|
|
79
|
+
// 去重 (本地优先)
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
return out.filter(i => {
|
|
82
|
+
if (seen.has(i.label))
|
|
83
|
+
return false;
|
|
84
|
+
seen.add(i.label);
|
|
85
|
+
return true;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// ─── 技能 (/ 弹窗的一部分) ───────────────────────────────────────────────────
|
|
89
|
+
/** 3 个 skill 目录 (与 skill-loader.defaultSkillPaths 一致) */
|
|
90
|
+
export async function loadSkills() {
|
|
91
|
+
const out = [];
|
|
92
|
+
const dirs = [
|
|
93
|
+
path.join(HOME(), '.bolloon', 'skills'),
|
|
94
|
+
path.join(process.cwd(), '.bolloon', 'skills'),
|
|
95
|
+
path.join(HOME(), '.boll', 'skills'),
|
|
96
|
+
];
|
|
97
|
+
for (const d of dirs) {
|
|
98
|
+
let entries;
|
|
99
|
+
try {
|
|
100
|
+
entries = await fs.readdir(d, { withFileTypes: true });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const e of entries) {
|
|
106
|
+
if (!e.isDirectory() || e.name.startsWith('.'))
|
|
107
|
+
continue;
|
|
108
|
+
// 读 frontmatter description 作为提示 (轻量, 不引 skill-loader 依赖)
|
|
109
|
+
let desc = '';
|
|
110
|
+
try {
|
|
111
|
+
const raw = (await fs.readFile(path.join(d, e.name, 'SKILL.md'), 'utf-8')).slice(0, 800);
|
|
112
|
+
const m = raw.match(/^---\r?\n[\s\S]*?^description:\s*(.+)$/m);
|
|
113
|
+
if (m)
|
|
114
|
+
desc = m[1].trim().replace(/^["']|["']$/g, '').slice(0, 50);
|
|
115
|
+
}
|
|
116
|
+
catch { /* 无 SKILL.md → 仅目录名 */ }
|
|
117
|
+
out.push({ kind: 'skill', label: e.name, hint: desc || '技能', insert: e.name });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
return out.filter(i => {
|
|
122
|
+
if (seen.has(i.label))
|
|
123
|
+
return false;
|
|
124
|
+
seen.add(i.label);
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// ─── 插件 (/ 弹窗的一部分) — MCP servers ─────────────────────────────────────
|
|
129
|
+
/** ~/.mcp.json / cwd/.mcp.json 的 mcpServers 键名 */
|
|
130
|
+
export async function loadPlugins() {
|
|
131
|
+
const out = [];
|
|
132
|
+
const paths = [path.join(HOME(), '.mcp.json'), path.join(process.cwd(), '.mcp.json')];
|
|
133
|
+
for (const p of paths) {
|
|
134
|
+
try {
|
|
135
|
+
const data = JSON.parse(await fs.readFile(p, 'utf-8'));
|
|
136
|
+
const servers = data?.mcpServers;
|
|
137
|
+
if (servers && typeof servers === 'object') {
|
|
138
|
+
for (const name of Object.keys(servers)) {
|
|
139
|
+
out.push({ kind: 'plugin', label: name, hint: 'MCP 插件', insert: name });
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch { /* 下一个路径 */ }
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
// ─── 文件 (#) ────────────────────────────────────────────────────────────────
|
|
149
|
+
const SKIP_DIRS = new Set([
|
|
150
|
+
'node_modules', '.git', 'dist', 'build', 'out', 'target', '.cache', '.hermes',
|
|
151
|
+
'coverage', '.next', '.nuxt', '.venv', 'venv', '__pycache__', 'tmp', 'release',
|
|
152
|
+
'.bolloon', '.boll', '.turbo', '.idea', '.vscode', 'bin', 'lib', 'assets',
|
|
153
|
+
]);
|
|
154
|
+
/** cwd 有限深度 BFS, 只收文件, 上限 cap 个, 排序稳定 */
|
|
155
|
+
export async function loadFiles(_query, base = process.cwd(), maxDepth = 3, cap = 400) {
|
|
156
|
+
const out = [];
|
|
157
|
+
const stack = [{ dir: base, depth: 0 }];
|
|
158
|
+
while (stack.length > 0 && out.length < cap) {
|
|
159
|
+
const { dir, depth } = stack.pop();
|
|
160
|
+
let entries;
|
|
161
|
+
try {
|
|
162
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
// 目录优先入栈, 文件直接收集; 同层排序保证稳定
|
|
168
|
+
const dirs = [];
|
|
169
|
+
for (const e of entries) {
|
|
170
|
+
if (out.length >= cap)
|
|
171
|
+
break;
|
|
172
|
+
if (e.name.startsWith('.'))
|
|
173
|
+
continue;
|
|
174
|
+
const full = path.join(dir, e.name);
|
|
175
|
+
if (e.isDirectory()) {
|
|
176
|
+
if (depth < maxDepth && !SKIP_DIRS.has(e.name))
|
|
177
|
+
dirs.push({ dir: full, depth: depth + 1 });
|
|
178
|
+
}
|
|
179
|
+
else if (e.isFile()) {
|
|
180
|
+
const rel = path.relative(base, full);
|
|
181
|
+
out.push({ kind: 'file', label: rel, hint: '', insert: rel });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
dirs.reverse(); // 保证 pop 顺序按字母序
|
|
185
|
+
for (const d of dirs)
|
|
186
|
+
stack.push(d);
|
|
187
|
+
}
|
|
188
|
+
out.sort((a, b) => a.label.localeCompare(b.label));
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
/** 文件匹配评分: basename 前缀 > 全路径前缀 > basename 包含 > 全路径包含 */
|
|
192
|
+
export function matchFileScore(label, q) {
|
|
193
|
+
const base = label.split('/').pop() || label;
|
|
194
|
+
if (base.startsWith(q))
|
|
195
|
+
return 0;
|
|
196
|
+
if (label.startsWith(q))
|
|
197
|
+
return 1;
|
|
198
|
+
if (base.includes(q))
|
|
199
|
+
return 2;
|
|
200
|
+
if (label.includes(q))
|
|
201
|
+
return 3;
|
|
202
|
+
return -1;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* 从输入里检测当前 mention token (最后一个以 @ / # 开头的 token).
|
|
206
|
+
* 返回 { kind, query, start, trigger } — start 是触发符在 input 里的下标.
|
|
207
|
+
*
|
|
208
|
+
* 规则:
|
|
209
|
+
* @ → agent: token 里最后一个 @, 且 @ 前一个字符不是 ascii 字母数字 (防 email a@b.com)
|
|
210
|
+
* # → file: token 里最后一个 #, 允许路径含 / (如 #src/cli/), 前字符非 ascii 字母数字
|
|
211
|
+
* / → command: 必须是 token 第一个字符 (防 "看看 src/" 误触发), 如 /queue
|
|
212
|
+
*/
|
|
213
|
+
export function getMention(input) {
|
|
214
|
+
// 最后一个 token 的起始下标 (最后一个空白之后)
|
|
215
|
+
let tokenStart = input.length;
|
|
216
|
+
while (tokenStart > 0 && !/\s/.test(input[tokenStart - 1]))
|
|
217
|
+
tokenStart--;
|
|
218
|
+
const token = input.slice(tokenStart);
|
|
219
|
+
// 从后往前找触发符
|
|
220
|
+
for (let i = token.length - 1; i >= 0; i--) {
|
|
221
|
+
const ch = token[i];
|
|
222
|
+
if (ch !== '@' && ch !== '/' && ch !== '#')
|
|
223
|
+
continue;
|
|
224
|
+
if (ch === '/') {
|
|
225
|
+
// 命令: 只认 token 第一个字符
|
|
226
|
+
if (i !== 0)
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
// @ / #: 前一个字符是 ascii 字母数字 → email/url 场景, 跳过
|
|
231
|
+
if (i > 0 && /[A-Za-z0-9]/.test(token[i - 1]))
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const kind = ch === '@' ? 'agent' : ch === '#' ? 'file' : 'command';
|
|
235
|
+
return { kind, query: token.slice(i + 1), start: tokenStart + i, trigger: ch };
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -415,11 +415,10 @@ async function startCLI(comm) {
|
|
|
415
415
|
// 进入 Ink TUI 输入循环
|
|
416
416
|
const initialStatus = `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ 0s`;
|
|
417
417
|
const getStatus = () => {
|
|
418
|
-
const dur = Math.floor((Date.now() - cliStartTime) / 1000);
|
|
419
418
|
const barLen = 12;
|
|
420
419
|
const filled = Math.round((cliContextPct / 100) * barLen);
|
|
421
420
|
const bar = '█'.repeat(filled) + '░'.repeat(barLen - filled);
|
|
422
|
-
return `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ ${
|
|
421
|
+
return `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ ${fmtDuration(Date.now() - cliStartTime)}\x1b[90m │\x1b[0m ${bar} ${cliContextPct}%`;
|
|
423
422
|
};
|
|
424
423
|
startInk((text) => { processInput(text, comm); }, initialStatus, getStatus);
|
|
425
424
|
// Wait on a promise that resolves on Ctrl+C / 双击 Esc
|
|
@@ -494,6 +493,11 @@ async function processInput(input, comm) {
|
|
|
494
493
|
appendLine(` ${C_ACCENT}!<cmd>${RESET} 执行终端命令 ${C_DIM}如 !ls -la${RESET}`);
|
|
495
494
|
appendLine(` ${C_ACCENT}/queue${RESET} 切换队列模式 ${C_DIM}输入排队, 当前结束后自动执行${RESET}`);
|
|
496
495
|
appendLine(` ${C_ACCENT}/dequeue${RESET} 出队一条`);
|
|
496
|
+
appendLine(` ${C_ACCENT}@名字${RESET} @ 命中智能体 ${C_DIM}弹出窗选择后发送给智能体${RESET}`);
|
|
497
|
+
appendLine(` ${C_ACCENT}/名字${RESET} / 命中命令/技能/插件 ${C_DIM}输入 / 自动弹出${RESET}`);
|
|
498
|
+
appendLine(` ${C_ACCENT}#路径${RESET} # 命中文件 ${C_DIM}输入 # 自动弹出文件列表${RESET}`);
|
|
499
|
+
appendLine(` ${C_ACCENT}Tab${RESET} 补齐命令 ${C_DIM}普通输入也能 Tab 补 /命令 use_skill 技能 @智能体 #文件${RESET}`);
|
|
500
|
+
appendLine(` ${C_ACCENT}↑/↓${RESET} 切换历史输入 ${C_DIM}↑ 翻上一条, ↓ 回下一条/草稿${RESET}`);
|
|
497
501
|
appendLine(` ${C_ACCENT}peers${RESET} 查看 P2P 节点`);
|
|
498
502
|
appendLine(` ${C_ACCENT}iroh${RESET} 查看 iroh 状态`);
|
|
499
503
|
appendLine(` ${C_ACCENT}add_friend${RESET} 添加好友`);
|
|
@@ -617,11 +621,10 @@ async function processInput(input, comm) {
|
|
|
617
621
|
try {
|
|
618
622
|
const msgLen = JSON.stringify(a.messageHistory ?? []).length;
|
|
619
623
|
cliContextPct = Math.min(100, Math.round((msgLen / 240_000) * 100));
|
|
620
|
-
const dur = Math.floor((Date.now() - cliStartTime) / 1000);
|
|
621
624
|
const barLen = 12;
|
|
622
625
|
const filled = Math.round((cliContextPct / 100) * barLen);
|
|
623
626
|
const bar = '█'.repeat(filled) + '░'.repeat(barLen - filled);
|
|
624
|
-
const statusText = `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ ${
|
|
627
|
+
const statusText = `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ ${fmtDuration(Date.now() - cliStartTime)} \x1b[90m│\x1b[0m ${msgLen.toLocaleString()}B/240K │ ${bar} ${cliContextPct}%`;
|
|
625
628
|
inkSetStatus(statusText);
|
|
626
629
|
}
|
|
627
630
|
catch { /* 降级容忍 */ }
|