@bolloon/bolloon-agent 0.3.31 → 0.3.33

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.
@@ -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... Esc \u53CC\u51FB\u9000\u51FA \u00B7 /queue \u6392\u961F \u00B7 !\u7EC8\u7AEF\u547D\u4EE4" })] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) })] }));
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,241 @@
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 个, 排序稳定; 超时兜底 (冷缓存 fs.readdir 偶发挂起, 2026-08-05) */
155
+ export async function loadFiles(_query, base = process.cwd(), maxDepth = 3, cap = 400, timeoutMs = 5000) {
156
+ const out = [];
157
+ const deadline = Date.now() + timeoutMs;
158
+ const stack = [{ dir: base, depth: 0 }];
159
+ while (stack.length > 0 && out.length < cap) {
160
+ if (Date.now() > deadline)
161
+ break; // 超时返回已收集部分, 弹窗不卡"扫描中"
162
+ const { dir, depth } = stack.pop();
163
+ let entries;
164
+ try {
165
+ entries = await fs.readdir(dir, { withFileTypes: true });
166
+ }
167
+ catch {
168
+ continue;
169
+ }
170
+ // 目录优先入栈, 文件直接收集; 同层排序保证稳定
171
+ const dirs = [];
172
+ for (const e of entries) {
173
+ if (out.length >= cap)
174
+ break;
175
+ if (e.name.startsWith('.'))
176
+ continue;
177
+ const full = path.join(dir, e.name);
178
+ if (e.isDirectory()) {
179
+ if (depth < maxDepth && !SKIP_DIRS.has(e.name))
180
+ dirs.push({ dir: full, depth: depth + 1 });
181
+ }
182
+ else if (e.isFile()) {
183
+ const rel = path.relative(base, full);
184
+ out.push({ kind: 'file', label: rel, hint: '', insert: rel });
185
+ }
186
+ }
187
+ dirs.reverse(); // 保证 pop 顺序按字母序
188
+ for (const d of dirs)
189
+ stack.push(d);
190
+ }
191
+ out.sort((a, b) => a.label.localeCompare(b.label));
192
+ return out;
193
+ }
194
+ /** 文件匹配评分: basename 前缀 > 全路径前缀 > basename 包含 > 全路径包含 */
195
+ export function matchFileScore(label, q) {
196
+ const base = label.split('/').pop() || label;
197
+ if (base.startsWith(q))
198
+ return 0;
199
+ if (label.startsWith(q))
200
+ return 1;
201
+ if (base.includes(q))
202
+ return 2;
203
+ if (label.includes(q))
204
+ return 3;
205
+ return -1;
206
+ }
207
+ /**
208
+ * 从输入里检测当前 mention token (最后一个以 @ / # 开头的 token).
209
+ * 返回 { kind, query, start, trigger } — start 是触发符在 input 里的下标.
210
+ *
211
+ * 规则:
212
+ * @ → agent: token 里最后一个 @, 且 @ 前一个字符不是 ascii 字母数字 (防 email a@b.com)
213
+ * # → file: token 里最后一个 #, 允许路径含 / (如 #src/cli/), 前字符非 ascii 字母数字
214
+ * / → command: 必须是 token 第一个字符 (防 "看看 src/" 误触发), 如 /queue
215
+ */
216
+ export function getMention(input) {
217
+ // 最后一个 token 的起始下标 (最后一个空白之后)
218
+ let tokenStart = input.length;
219
+ while (tokenStart > 0 && !/\s/.test(input[tokenStart - 1]))
220
+ tokenStart--;
221
+ const token = input.slice(tokenStart);
222
+ // 从后往前找触发符
223
+ for (let i = token.length - 1; i >= 0; i--) {
224
+ const ch = token[i];
225
+ if (ch !== '@' && ch !== '/' && ch !== '#')
226
+ continue;
227
+ if (ch === '/') {
228
+ // 命令: 只认 token 第一个字符
229
+ if (i !== 0)
230
+ continue;
231
+ }
232
+ else {
233
+ // @ / #: 前一个字符是 ascii 字母数字 → email/url 场景, 跳过
234
+ if (i > 0 && /[A-Za-z0-9]/.test(token[i - 1]))
235
+ continue;
236
+ }
237
+ const kind = ch === '@' ? 'agent' : ch === '#' ? 'file' : 'command';
238
+ return { kind, query: token.slice(i + 1), start: tokenStart + i, trigger: ch };
239
+ }
240
+ return null;
241
+ }
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 ⏱ ${dur}s\x1b[90m │\x1b[0m ${bar} ${cliContextPct}%`;
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 ⏱ ${dur}s \x1b[90m│\x1b[0m ${msgLen.toLocaleString()}B/240K │ ${bar} ${cliContextPct}%`;
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 { /* 降级容忍 */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.3.31",
3
+ "version": "0.3.33",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli-entry.js",