@bolloon/bolloon-agent 0.4.17 → 0.4.18
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/content.js +10 -0
- package/dist/cli/ink-app.js +131 -70
- package/dist/cli/keymap.js +36 -0
- package/dist/cli/loading-tui.js +11 -8
- package/dist/cli/markdown.js +18 -0
- package/dist/cli/stores.js +64 -0
- package/dist/cli/theme.js +23 -0
- package/dist/cli/timing.js +8 -0
- package/dist/cli/widget-host.js +68 -0
- package/dist/index.js +175 -12
- package/package.json +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* content.ts — TUI 文案/提示 与组件解耦 (内容层分离, 便于统一改文案/多语言)
|
|
3
|
+
*/
|
|
4
|
+
export const COMPOSER_PLACEHOLDER = '输入消息... @智能体 /命令 #文件 · Esc 双击退出 · /queue 排队 · !终端命令';
|
|
5
|
+
export const POPUP_TITLE_TAB = 'Tab 补齐';
|
|
6
|
+
export const POPUP_TITLE_AGENT = '@ 智能体';
|
|
7
|
+
export const POPUP_TITLE_FILE = '# 文件';
|
|
8
|
+
export const POPUP_TITLE_COMMAND = '/ 命令 · 技能 · 插件';
|
|
9
|
+
export const CHAR_EXIT_HINT = '再按一次 Esc 退出当前进程';
|
|
10
|
+
export const CHAR_LOADING = ['(`・ω・´)', '(´・_・`)', '(。•́︿•̀。)', 'ᕙ(▀̿̿Ĺ̯̿̿▀̿ ̿)ᕗ', '(◕‿◕)', 'ヽ(´▽`)/'];
|
package/dist/cli/ink-app.js
CHANGED
|
@@ -7,39 +7,45 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
7
7
|
* 2026-08-05: @ / # 弹出选择窗 — 输入 @ 命中智能体, / 命中命令+技能+插件, # 命中文件
|
|
8
8
|
* ↑/↓ 导航, Tab/Enter 选中, Esc 关闭, 弹出窗打开时 TextInput 让出焦点 (focus=false)
|
|
9
9
|
*/
|
|
10
|
-
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
11
|
-
import { render, Box, Text, useInput, useApp } from 'ink';
|
|
10
|
+
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
11
|
+
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
12
12
|
import TextInput from 'ink-text-input';
|
|
13
|
-
import {
|
|
13
|
+
import { dispWidth, LOADING_FRAMES as KAOMOJI } from './loading-tui.js';
|
|
14
|
+
import { THEME, fg } from './theme.js';
|
|
15
|
+
import { COMPOSER_PLACEHOLDER, POPUP_TITLE_TAB, POPUP_TITLE_AGENT, POPUP_TITLE_FILE, POPUP_TITLE_COMMAND } from './content.js';
|
|
16
|
+
import { DOUBLE_ESC_MS, STATUS_TICK_MS, THINK_FRAME_MS } from './timing.js';
|
|
17
|
+
import { resolveNormalKey, applyScroll } from './keymap.js';
|
|
18
|
+
import { useWidgets } from './widget-host.js';
|
|
19
|
+
import { useStore, transcriptStore, uiStore, appendMsg, replaceLastMsg, replaceMarkerMsg, setUiStatus, setUiThinking, setUiTransient } from './stores.js';
|
|
14
20
|
import { loadAgents, loadCommands, loadSkills, loadPlugins, loadFiles, getMention, matchFileScore, } from './mention-data.js';
|
|
15
|
-
// ─── 组件: Logo Box ──────────────────────────────────────────────────────────
|
|
16
|
-
const LogoBox = ({ width }) => {
|
|
17
|
-
const art = brandArtLines();
|
|
18
|
-
const mw = Math.max(40, ...art.map(l => dispWidth(l))) + 4;
|
|
19
|
-
const bw = Math.min(width - 2, mw);
|
|
20
|
-
const rows = [boxTop('Bolloon Agent', bw)];
|
|
21
|
-
for (const l of art)
|
|
22
|
-
rows.push(boxRow(l, bw, 'center'));
|
|
23
|
-
rows.push(boxBottom(bw));
|
|
24
|
-
return (_jsx(Box, { flexDirection: "column", children: rows.map((r, i) => _jsx(Text, { children: r }, i)) }));
|
|
25
|
-
};
|
|
26
21
|
// ─── 组件: 消息列表 ──────────────────────────────────────────────────────────
|
|
27
|
-
|
|
22
|
+
/** 消息显示行数 (ANSI 剥离后按宽度 wrap 估行; 与 Ink 按父宽 wrap 近似一致) */
|
|
23
|
+
function msgVisualLines(text, width) {
|
|
24
|
+
let n = 0;
|
|
25
|
+
const clean = text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
26
|
+
for (const line of clean.split('\n')) {
|
|
27
|
+
const w = dispWidth(line);
|
|
28
|
+
n += Math.max(1, Math.ceil(w / Math.max(10, width - 1)));
|
|
29
|
+
}
|
|
30
|
+
return n;
|
|
31
|
+
}
|
|
32
|
+
// 2026-09-08: React.memo — msgs 引用不变时跳过重渲染 (配合虚拟化 slice, status tick 不再整表重绘)
|
|
33
|
+
const Messages = React.memo(({ msgs }) => (_jsx(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "flex-start", children: msgs.map((m, i) => {
|
|
28
34
|
const clean = m.replace(/\x1b\[[0-9;]*m/g, '');
|
|
29
35
|
return clean.trim() ? _jsx(Text, { children: clean ? m : '' }, i) : null;
|
|
30
|
-
}) }));
|
|
36
|
+
}) })));
|
|
31
37
|
const MentionPopup = ({ title, items, sel, width, loading }) => {
|
|
32
38
|
const MAX_ROWS = 8;
|
|
33
39
|
// 2026-08-08: 滑动窗口 — 选中项始终可见 (原实现 fix 屏幕顶部, sel 超窗口时无高亮行)
|
|
34
40
|
const offset = Math.max(0, Math.min(sel - Math.floor(MAX_ROWS / 2), Math.max(0, items.length - MAX_ROWS)));
|
|
35
41
|
const shown = items.slice(offset, offset + MAX_ROWS);
|
|
36
42
|
const innerW = Math.max(width - 2, 10);
|
|
37
|
-
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Text, { color:
|
|
43
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Text, { color: THEME.accent, 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) => {
|
|
38
44
|
const active = i === sel;
|
|
39
45
|
const label = it.kind === 'file' ? it.label : `${it.kind === 'skill' ? '⚡' : it.kind === 'plugin' ? '🔌' : ''}${it.label}`;
|
|
40
46
|
const hint = it.hint ? `${it.hint}` : it.kind === 'file' ? '文件' : '';
|
|
41
47
|
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}`));
|
|
42
|
-
})), items.length > MAX_ROWS && (_jsxs(Text, { color: "dim", children: ["\u2502 ", offset + 1, "-", offset + shown.length, "/", items.length, " \u00B7 \u8FD8\u6709 ", items.length - (offset + shown.length), " \u9879..."] })), _jsx(Text, { color:
|
|
48
|
+
})), items.length > MAX_ROWS && (_jsxs(Text, { color: "dim", children: ["\u2502 ", offset + 1, "-", offset + shown.length, "/", items.length, " \u00B7 \u8FD8\u6709 ", items.length - (offset + shown.length), " \u9879..."] })), _jsx(Text, { color: THEME.accent, children: `╰${'─'.repeat(innerW)}╯` })] }));
|
|
43
49
|
};
|
|
44
50
|
const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH }) => {
|
|
45
51
|
const [input, setInput] = useState('');
|
|
@@ -50,17 +56,23 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
50
56
|
}, [input]);
|
|
51
57
|
// 2026-08-07: 提交防重 (InkApp \n/\r 兜底 + TextInput 双触发场景)
|
|
52
58
|
const lastSubmitRef = useRef({ t: 0, v: '' });
|
|
53
|
-
const
|
|
54
|
-
const
|
|
59
|
+
const msgs = useStore(transcriptStore); // #2 状态外置: transcript 来自外部 store
|
|
60
|
+
const ui = useStore(uiStore); // #2 status/thinking/transient 外置
|
|
61
|
+
const status = ui.status || initialStatus;
|
|
62
|
+
const thinking = ui.thinking;
|
|
63
|
+
const transient = ui.transient;
|
|
64
|
+
// #8 占用槽: 右侧 rails (有 widget 占宽, 无则布局不变)
|
|
65
|
+
const widgets = useWidgets();
|
|
66
|
+
const railNames = Object.keys(widgets);
|
|
67
|
+
const hasRails = railNames.length > 0;
|
|
55
68
|
const { exit } = useApp();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const [transient, setTransient] = useState(null);
|
|
69
|
+
// 虚拟化滚动: 行窗口 top + 是否跟随底部 (用户上滚后自动跟随关闭, End 恢复)
|
|
70
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
71
|
+
const stickRef = useRef(true);
|
|
60
72
|
const thinkingIdx = useRef(0);
|
|
61
73
|
// 双击 Esc 退出当前进程 (500ms 窗口内第二次按下)
|
|
62
74
|
const lastEscRef = useRef(0);
|
|
63
|
-
const C_WARN_ANSI =
|
|
75
|
+
const C_WARN_ANSI = fg(THEME.warn); // #f59e0b
|
|
64
76
|
// ── @ / # 弹出窗状态 ──────────────────────────────────────────────────────
|
|
65
77
|
const mention = useMemo(() => getMention(input), [input]);
|
|
66
78
|
const mentionKey = mention ? `${mention.kind}:${mention.start}` : null;
|
|
@@ -179,10 +191,10 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
179
191
|
}, [items, mention, tabState]);
|
|
180
192
|
const popupOpen = !!(tabState || (mention && dismissed !== mentionKey));
|
|
181
193
|
const safeSel = Math.min(sel, Math.max(0, filtered.length - 1));
|
|
182
|
-
const popupTitle = tabState ?
|
|
183
|
-
: mention?.kind === 'agent' ?
|
|
184
|
-
: mention?.kind === 'file' ?
|
|
185
|
-
:
|
|
194
|
+
const popupTitle = tabState ? POPUP_TITLE_TAB
|
|
195
|
+
: mention?.kind === 'agent' ? POPUP_TITLE_AGENT
|
|
196
|
+
: mention?.kind === 'file' ? POPUP_TITLE_FILE
|
|
197
|
+
: POPUP_TITLE_COMMAND;
|
|
186
198
|
// 在指定 start 位置插入补齐文本 (函数式更新, 闭包安全)
|
|
187
199
|
const insertAt = useCallback((start, it) => {
|
|
188
200
|
setInput(cur => {
|
|
@@ -276,34 +288,52 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
276
288
|
}
|
|
277
289
|
}, [input, insertAt]);
|
|
278
290
|
const [tiKey, setTiKey] = useState(0);
|
|
291
|
+
// 2026-09-08 (Hermes TUI 学习落地): 实时终端尺寸 — 原来 terminalW/H 在 mount 时冻结,
|
|
292
|
+
// 终端 resize 后分隔线/logo 宽度全错位 (Hermes 用 resizeCoalescer + 实时 layout)。
|
|
293
|
+
// 订阅 stdout resize, 每次渲染用最新列数。
|
|
294
|
+
const { stdout } = useStdout();
|
|
295
|
+
const [termSize, setTermSize] = useState({ w: terminalW, h: terminalH });
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
// resizeCoalescer (#9): 拖拽终端时会连发 resize — 聚合到 80ms 空闲后一次性应用, 避免中间态跳帧
|
|
298
|
+
let raf = null;
|
|
299
|
+
let pending = null;
|
|
300
|
+
const apply = () => {
|
|
301
|
+
if (pending) {
|
|
302
|
+
const p = pending;
|
|
303
|
+
pending = null;
|
|
304
|
+
setTermSize(p);
|
|
305
|
+
}
|
|
306
|
+
raf = null;
|
|
307
|
+
};
|
|
308
|
+
const update = () => {
|
|
309
|
+
pending = { w: stdout?.columns || terminalW, h: stdout?.rows || terminalH };
|
|
310
|
+
if (raf)
|
|
311
|
+
clearTimeout(raf);
|
|
312
|
+
raf = setTimeout(apply, 80);
|
|
313
|
+
};
|
|
314
|
+
update();
|
|
315
|
+
stdout?.on?.('resize', update);
|
|
316
|
+
return () => { stdout?.removeListener?.('resize', update); if (raf)
|
|
317
|
+
clearTimeout(raf); };
|
|
318
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
319
|
+
}, [stdout]);
|
|
320
|
+
const W = termSize.w;
|
|
279
321
|
// 全局: 思考动画控制
|
|
280
322
|
useEffect(() => {
|
|
281
323
|
// 2026-08-06: 防御 — 某些环境 (tsx/完整 CLI 初始化) 下 stdin 会处于 paused,
|
|
282
324
|
// 不恢复则 useInput 收不到任何输入 (实测 isPaused=true, listeners=0)
|
|
283
325
|
if (process.stdin.isPaused())
|
|
284
326
|
process.stdin.resume();
|
|
285
|
-
globalThis.__inkSetThinking = (v) =>
|
|
286
|
-
globalThis.__inkAppend = (line) =>
|
|
287
|
-
|
|
288
|
-
};
|
|
289
|
-
globalThis.__inkSetStatus = (s) => {
|
|
290
|
-
setStatus(s);
|
|
291
|
-
};
|
|
327
|
+
globalThis.__inkSetThinking = (v) => setUiThinking(v);
|
|
328
|
+
globalThis.__inkAppend = (line) => appendMsg(line);
|
|
329
|
+
globalThis.__inkSetStatus = (s) => setUiStatus(s);
|
|
292
330
|
// 2026-08-10: 临时状态行 (自动整理/经验整理): 传字符串显示, 传 null 清空 (显示为空)
|
|
293
|
-
globalThis.__inkSetTransient = (v) =>
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
// 2026-08
|
|
297
|
-
//
|
|
298
|
-
globalThis.
|
|
299
|
-
setMsgs(prev => {
|
|
300
|
-
if (prev.length === 0)
|
|
301
|
-
return [...prev, line];
|
|
302
|
-
const next = prev.slice();
|
|
303
|
-
next[next.length - 1] = line;
|
|
304
|
-
return next;
|
|
305
|
-
});
|
|
306
|
-
};
|
|
331
|
+
globalThis.__inkSetTransient = (v) => setUiTransient(v);
|
|
332
|
+
// 2026-08-12 (Task4): 原地替换最后一条消息 (命令加载态 → 完成态用). 不命中则追加.
|
|
333
|
+
globalThis.__inkReplaceLast = (line) => replaceLastMsg(line);
|
|
334
|
+
// 2026-09-08: 按内容匹配替换 — 占位框可能在 P2P/连接消息之后才被替换,
|
|
335
|
+
// __inkReplaceLast 会覆盖错一条; 用字符串标记精确定位要替换的消息.
|
|
336
|
+
globalThis.__inkReplaceMatching = (marker, line) => replaceMarkerMsg(marker, line);
|
|
307
337
|
return () => {
|
|
308
338
|
delete globalThis.__inkAppend;
|
|
309
339
|
delete globalThis.__inkSetStatus;
|
|
@@ -443,6 +473,18 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
443
473
|
return; // 其余键忽略 (return/tab/esc 等由 TextInput 或上层处理)
|
|
444
474
|
}
|
|
445
475
|
// ── 正常模式 ──
|
|
476
|
+
// #7 输入层: 滚动键走数据化 keymap (Ctrl+U/D / PgUp/PgDn / Home-End / Alt+↑↓ / Ctrl+Home-End)
|
|
477
|
+
if (totalLines > availH) {
|
|
478
|
+
const pg = Math.max(6, availH - 2);
|
|
479
|
+
const maxT = Math.max(0, totalLines - availH);
|
|
480
|
+
const act = resolveNormalKey(key, { scrollable: true, input: _input });
|
|
481
|
+
if (act !== 'none') {
|
|
482
|
+
const r = applyScroll(act, stickRef.current ? maxT : (scrollTop || 0), pg, maxT);
|
|
483
|
+
stickRef.current = r.stick;
|
|
484
|
+
setScrollTop(r.next);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
446
488
|
// 2026-08-07: Enter 兜底 — pty/管道下 termios 可能把 \r 转 \n 且 node 把整 chunk
|
|
447
489
|
// 当一次 keypress (key.return=false), TextInput 的 onSubmit 永不触发 → 消息发不出去.
|
|
448
490
|
// 应用层把 \n/\r 一律视为提交 (兼容 raw/cooked 两种模式, 不依赖 termios).
|
|
@@ -488,7 +530,7 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
488
530
|
// 双击 Esc 退出当前进程: 第一击提示, 500ms 内第二击退出
|
|
489
531
|
if (key.escape) {
|
|
490
532
|
const now = Date.now();
|
|
491
|
-
if (now - lastEscRef.current <
|
|
533
|
+
if (now - lastEscRef.current < DOUBLE_ESC_MS) {
|
|
492
534
|
requestExit();
|
|
493
535
|
}
|
|
494
536
|
else {
|
|
@@ -522,17 +564,17 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
522
564
|
try {
|
|
523
565
|
const s0 = getStatusUpdate();
|
|
524
566
|
if (s0)
|
|
525
|
-
|
|
567
|
+
setUiStatus(s0);
|
|
526
568
|
}
|
|
527
569
|
catch { /* 状态栏更新失败不致命 */ }
|
|
528
570
|
const timer = setInterval(() => {
|
|
529
571
|
try {
|
|
530
572
|
const s = getStatusUpdate();
|
|
531
573
|
if (s)
|
|
532
|
-
|
|
574
|
+
setUiStatus(s);
|
|
533
575
|
}
|
|
534
576
|
catch { /* 状态栏更新失败不致命 */ }
|
|
535
|
-
},
|
|
577
|
+
}, STATUS_TICK_MS);
|
|
536
578
|
return () => clearInterval(timer);
|
|
537
579
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
538
580
|
}, []);
|
|
@@ -549,17 +591,40 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
549
591
|
items: filtered.slice(0, 5).map(i => i.kind + ':' + i.label),
|
|
550
592
|
});
|
|
551
593
|
}, [popupOpen, mentionKey, filtered]);
|
|
552
|
-
// 思考动画 — kaomoji 旋转
|
|
553
|
-
const KAOMOJI = ['(`・ω・´)', '(´・_・`)', '(。•́︿•̀。)', 'ᕙ(▀̿̿Ĺ̯̿̿▀̿ ̿)ᕗ', '(◕‿◕)'];
|
|
594
|
+
// 思考动画 — kaomoji 旋转 (帧序列单一来源: loading-tui LOADING_FRAMES, 2026-09-08)
|
|
554
595
|
useEffect(() => {
|
|
555
596
|
if (!thinking)
|
|
556
597
|
return;
|
|
557
598
|
const timer = setInterval(() => {
|
|
558
599
|
thinkingIdx.current = (thinkingIdx.current + 1) % KAOMOJI.length;
|
|
559
|
-
},
|
|
600
|
+
}, THINK_FRAME_MS);
|
|
560
601
|
return () => clearInterval(timer);
|
|
561
602
|
}, [thinking]);
|
|
562
|
-
|
|
603
|
+
// ── 虚拟化 transcript (消息级窗口 + 自动跟随底部) ───────────────────────────
|
|
604
|
+
// #1 分区预留: chrome 固定行 = 分隔线×3 + 状态栏 + 输入栏 = 5; transcript 严格不超区
|
|
605
|
+
// (content Box flexGrow=1 但内容超宽会把 status/composer 挤走 → 可见窗口行数钳制 ≤ availH)
|
|
606
|
+
const availH = Math.max(6, termSize.h - 5);
|
|
607
|
+
const heights = useMemo(() => msgs.map(m => msgVisualLines(m, W)), [msgs, W]);
|
|
608
|
+
const cumulative = useMemo(() => {
|
|
609
|
+
const c = [0];
|
|
610
|
+
for (const h of heights)
|
|
611
|
+
c.push(c[c.length - 1] + h);
|
|
612
|
+
return c;
|
|
613
|
+
}, [heights]);
|
|
614
|
+
const totalLines = cumulative[cumulative.length - 1] || 0;
|
|
615
|
+
const maxTop = Math.max(0, totalLines - availH);
|
|
616
|
+
const sticky = stickRef.current;
|
|
617
|
+
const top = sticky ? maxTop : Math.min(scrollTop, maxTop);
|
|
618
|
+
let start = 0;
|
|
619
|
+
while (start < msgs.length && cumulative[start + 1] <= top + 0.5)
|
|
620
|
+
start++;
|
|
621
|
+
let end = start;
|
|
622
|
+
// 严格钳制: 端界不越过 availH, 保证 transcript 内容行数 ≤ 分区高度 (互不覆盖)
|
|
623
|
+
while (end + 1 < msgs.length && cumulative[end + 1] <= top + availH)
|
|
624
|
+
end++;
|
|
625
|
+
const visible = useMemo(() => msgs.slice(start, end + 1), [msgs, start, end]);
|
|
626
|
+
const scrolledOut = maxTop > 0 && !sticky;
|
|
627
|
+
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { flexGrow: 1, flexDirection: hasRails ? 'row' : 'column', children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", children: [_jsx(Messages, { msgs: visible }), scrolledOut && (_jsxs(Text, { color: THEME.muted, children: ["\u25BE \u4E0A\u6EDA ", top, " \u884C \u00B7 Ctrl+U/D \u7FFB\u9875 \u00B7 Home \u9876 \u00B7 End \u56DE\u5E95"] })), thinking && (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] }) })), transient && (_jsx(Box, { children: _jsx(Text, { children: transient }) }))] }), hasRails && (_jsx(Box, { width: 36, marginLeft: 1, flexDirection: "column", justifyContent: "flex-start", children: railNames.map((n) => _jsxs(Text, { color: THEME.muted, children: [n, "\\n", widgets[n]] }, n)) }))] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: THEME.accent, children: '─'.repeat(W) }) }), _jsx(Box, { children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: THEME.accent, children: '─'.repeat(W) }) }), popupOpen && (tabState || mention) && (_jsx(MentionPopup, { title: popupTitle, items: filtered, sel: safeSel, width: W, loading: loadingFiles })), picker && (_jsx(MentionPopup, { title: picker.title, items: picker.items, sel: Math.min(picker.sel, picker.items.length - 1), width: W })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: THEME.accent, children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, focus: !popupOpen && !picker, placeholder: COMPOSER_PLACEHOLDER }, tiKey)] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: THEME.accent, children: '─'.repeat(W) }) })] }));
|
|
563
628
|
};
|
|
564
629
|
export { InkApp };
|
|
565
630
|
// ─── 启动 ────────────────────────────────────────────────────────────────────
|
|
@@ -582,15 +647,15 @@ export function stopInk() {
|
|
|
582
647
|
}
|
|
583
648
|
}
|
|
584
649
|
export function inkAppendLine(line) {
|
|
585
|
-
|
|
586
|
-
if (fn)
|
|
587
|
-
fn(line);
|
|
650
|
+
appendMsg(line);
|
|
588
651
|
}
|
|
589
652
|
/** 2026-08-12 (Task4): 原地替换最后一条消息 (命令加载态 → 完成态). 无消息时追加. */
|
|
590
653
|
export function inkReplaceLastLine(line) {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
654
|
+
replaceLastMsg(line);
|
|
655
|
+
}
|
|
656
|
+
/** 2026-09-08: 按内容标记原地替换一条消息 (占位框 → 启动面板完整内容). */
|
|
657
|
+
export function inkReplaceMatchingLine(marker, line) {
|
|
658
|
+
replaceMarkerMsg(marker, line);
|
|
594
659
|
}
|
|
595
660
|
export function inkSetStatus(s) {
|
|
596
661
|
const fn = globalThis.__inkSetStatus;
|
|
@@ -598,13 +663,9 @@ export function inkSetStatus(s) {
|
|
|
598
663
|
fn(s);
|
|
599
664
|
}
|
|
600
665
|
export function inkSetThinking(v) {
|
|
601
|
-
|
|
602
|
-
if (fn)
|
|
603
|
-
fn(v);
|
|
666
|
+
setUiThinking(v);
|
|
604
667
|
}
|
|
605
668
|
/** 2026-08-10: 设置/清除临时状态行 (自动整理/经验整理). 传 null 清空 → 显示为空 */
|
|
606
669
|
export function inkSetTransient(v) {
|
|
607
|
-
|
|
608
|
-
if (fn)
|
|
609
|
-
fn(v);
|
|
670
|
+
setUiTransient(v);
|
|
610
671
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// ─── 输入层 keymap (Hermes 学习 #7) ────────────────────────────────────────
|
|
2
|
+
// 正常模式按键绑定表 + 纯函数 resolver: useInput 只做分发, 键->意图映射集中在这,
|
|
3
|
+
// 便于单测与扩展 (新增 Ctrl/Meta 组合不再改 useInput 巨块).
|
|
4
|
+
// 注: OSC52 剪贴板 / 精确滚轮 依赖终端能力, Ink useInput 不暴露, 不在本表范围.
|
|
5
|
+
/** 纯函数绑定表: 键 -> 意图 (仅在 scrollable 时响应滚动) */
|
|
6
|
+
export function resolveNormalKey(key, ctx) {
|
|
7
|
+
if (!ctx.scrollable)
|
|
8
|
+
return 'none';
|
|
9
|
+
const ch = ctx.input.toLowerCase();
|
|
10
|
+
// 上滚: Ctrl+U / PgUp / Alt+↑
|
|
11
|
+
if (key.ctrl && ch === 'u' || key.pageUp || key.meta && key.upArrow)
|
|
12
|
+
return 'scrollUp';
|
|
13
|
+
// 下滚: Ctrl+D / PgDn / Alt+↓
|
|
14
|
+
if (key.ctrl && ch === 'd' || key.pageDown || key.meta && key.downArrow)
|
|
15
|
+
return 'scrollDown';
|
|
16
|
+
// 顶部: Home / Ctrl+Home
|
|
17
|
+
if (key.home || key.ctrl && ch === 'a')
|
|
18
|
+
return 'scrollHome';
|
|
19
|
+
// 底部: End / Ctrl+End
|
|
20
|
+
if (key.end || key.ctrl && ch === 'e')
|
|
21
|
+
return 'scrollEnd';
|
|
22
|
+
return 'none';
|
|
23
|
+
}
|
|
24
|
+
/** 把意图映射成 scrollTop 平移量 (纯函数, 便于断言) */
|
|
25
|
+
export function applyScroll(act, cur, page, maxTop) {
|
|
26
|
+
switch (act) {
|
|
27
|
+
case 'scrollUp': return { next: Math.max(0, Math.min(cur - page, maxTop)), stick: false };
|
|
28
|
+
case 'scrollDown': {
|
|
29
|
+
const nx = Math.min(cur + page, maxTop);
|
|
30
|
+
return { next: nx, stick: nx >= maxTop };
|
|
31
|
+
}
|
|
32
|
+
case 'scrollHome': return { next: 0, stick: false };
|
|
33
|
+
case 'scrollEnd': return { next: maxTop, stick: true };
|
|
34
|
+
default: return { next: cur, stick: false };
|
|
35
|
+
}
|
|
36
|
+
}
|
package/dist/cli/loading-tui.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import * as fs from 'fs';
|
|
15
15
|
import * as path from 'path';
|
|
16
16
|
import { fileURLToPath } from 'url';
|
|
17
|
+
import { mdInline } from './markdown.js';
|
|
17
18
|
const RESET = '\x1b[0m';
|
|
18
19
|
const BOLD = '\x1b[1m';
|
|
19
20
|
const DIM = '\x1b[2m';
|
|
@@ -48,7 +49,7 @@ const BOLLOON_VERSION = getPackageVersion();
|
|
|
48
49
|
// ── 品牌图标: 笑脸机器人 (2026-08-09, bolloon 色系填充) ──────
|
|
49
50
|
// 头: 主色边框 + 亮绿填充 (C_ACCENT_BG); 眼睛 ◉ / 嘴 ◡ 用亮色填充;
|
|
50
51
|
// 末行 BOLLOON 主色艺术字 (仅 printBanner 用, brandArtLines 会裁掉避免双 logo).
|
|
51
|
-
const ROBOT_HEAD = [
|
|
52
|
+
export const ROBOT_HEAD = [
|
|
52
53
|
`${C_ACCENT} ╭───────╮${RESET}`,
|
|
53
54
|
`${C_ACCENT} ╭─╯${C_ACCENT_BG} ${C_WHITE}◉${C_ACCENT_BG} ${C_WHITE}◉${C_ACCENT_BG} ${RESET}${C_ACCENT}╰─╮${RESET}`,
|
|
54
55
|
`${C_ACCENT} │${C_ACCENT_BG} ${C_WHITE}◡${C_ACCENT_BG} ${RESET}${C_ACCENT}│${RESET}`,
|
|
@@ -328,11 +329,11 @@ function renderReference(opts) {
|
|
|
328
329
|
}
|
|
329
330
|
/** 已发送消息框 (用户输入) */
|
|
330
331
|
export function renderUserMessage(body) {
|
|
331
|
-
return renderMessageBox({ title: '✓ 已发送', body, color: C_OK, maxLines: DEFAULT_MAX_LINES });
|
|
332
|
+
return renderMessageBox({ title: '✓ 已发送', body: mdInline(body), color: C_OK, maxLines: DEFAULT_MAX_LINES });
|
|
332
333
|
}
|
|
333
334
|
/** 智能体回复框 (不压缩, 用户需要看到完整回复) */
|
|
334
335
|
export function renderAgentMessage(body) {
|
|
335
|
-
return renderMessageBox({ title: '◉ Bolloon Agent', body, color: C_ACCENT, maxLines: 0 });
|
|
336
|
+
return renderMessageBox({ title: '◉ Bolloon Agent', body: mdInline(body), color: C_ACCENT, maxLines: 0 });
|
|
336
337
|
}
|
|
337
338
|
/** 循环工作流连接线: 用 ╼ ╾ 串联相邻工具框 */
|
|
338
339
|
export function flowConnector(width) {
|
|
@@ -423,7 +424,9 @@ export function renderToolCall(v) {
|
|
|
423
424
|
lines.push(boxBottom(w, RD));
|
|
424
425
|
return lines.join('\n');
|
|
425
426
|
}
|
|
426
|
-
|
|
427
|
+
// 2026-09-08 (Hermes TUI 学习): 帧序列单一来源 — loading-tui 导出, ink-app 思考动画复用,
|
|
428
|
+
// 原来两份拷贝 (FRAMES / KAOMOJI) 会漂移.
|
|
429
|
+
export const LOADING_FRAMES = ['(`・ω・´)', '(´・_・`)', '(。•́︿•̀。)', 'ᕙ(▀̿̿Ĺ̯̿̿▀̿ ̿)ᕗ', '(◕‿◕)', 'ヽ(´▽`)/'];
|
|
427
430
|
export class LoadingTUI {
|
|
428
431
|
write;
|
|
429
432
|
timer = null;
|
|
@@ -432,7 +435,6 @@ export class LoadingTUI {
|
|
|
432
435
|
currentLabel = 'Bolloon loading...';
|
|
433
436
|
finished = false;
|
|
434
437
|
ok = true;
|
|
435
|
-
width = 0;
|
|
436
438
|
constructor() {
|
|
437
439
|
this.write = process.stdout.write.bind(process.stdout);
|
|
438
440
|
}
|
|
@@ -448,7 +450,9 @@ export class LoadingTUI {
|
|
|
448
450
|
return 1 + brandArtLines().length + this.steps.length + 1 + 1;
|
|
449
451
|
}
|
|
450
452
|
draw(showSpinner) {
|
|
451
|
-
|
|
453
|
+
// 2026-09-08 (Hermes TUI 学习): 每次 draw 实时算宽度 — 原首次缓存 (this.width),
|
|
454
|
+
// 启动过程中 resize 终端 → 仪表盘整框错位; computeWidth 每次成本可忽略
|
|
455
|
+
const w = this.computeWidth();
|
|
452
456
|
const out = [];
|
|
453
457
|
out.push(boxTop('Bolloon Agent · 启动仪表盘', w));
|
|
454
458
|
for (const l of brandArtLines())
|
|
@@ -457,7 +461,7 @@ export class LoadingTUI {
|
|
|
457
461
|
out.push(boxRow(`${STATUS_SYMBOL[step.status]} ${step.label}`, w));
|
|
458
462
|
}
|
|
459
463
|
if (showSpinner) {
|
|
460
|
-
const sp = C_WARN +
|
|
464
|
+
const sp = C_WARN + LOADING_FRAMES[this.frameIdx % LOADING_FRAMES.length] + RESET;
|
|
461
465
|
out.push(boxRow(`${sp} ${this.currentLabel}`, w));
|
|
462
466
|
}
|
|
463
467
|
else {
|
|
@@ -470,7 +474,6 @@ export class LoadingTUI {
|
|
|
470
474
|
}
|
|
471
475
|
setSteps(steps) {
|
|
472
476
|
this.steps = steps.map(label => ({ label, status: 'pending' }));
|
|
473
|
-
this.width = 0;
|
|
474
477
|
if (this.timer)
|
|
475
478
|
this.draw(true);
|
|
476
479
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// ─── 内联 Markdown 分词渲染 (Hermes 学习 #4 之 MessageLine) ────────────────
|
|
2
|
+
// 把正文里的 `code` / **bold** / _italic_ / __underline__ 转成 ANSI 高亮,
|
|
3
|
+
// 供 renderUserMessage / renderAgentMessage 在出框前套用 (字节变长, dispWidth 剥 ANSI 不计宽).
|
|
4
|
+
// 仅内联分段; ```
|
|
5
|
+
// 多行代码块折叠/Thinking/ToolTrail 折叠 仍待续.
|
|
6
|
+
const R = '\x1b[0m';
|
|
7
|
+
const BOLD = '\x1b[1m';
|
|
8
|
+
const DIM = '\x1b[2m';
|
|
9
|
+
const UNDER = '\x1b[4m';
|
|
10
|
+
const CYAN = '\x1b[36m';
|
|
11
|
+
const GREEN = '\x1b[32m';
|
|
12
|
+
export function mdInline(s) {
|
|
13
|
+
return s
|
|
14
|
+
.replace(/`([^`]+)`/g, (_, c) => `${CYAN}${c}${R}`)
|
|
15
|
+
.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${BOLD}${c}${R}`)
|
|
16
|
+
.replace(/__([^_]+)__/g, (_, c) => `${UNDER}${c}${R}`)
|
|
17
|
+
.replace(/(^|[^*\w])\*([^*\s][^*]*)\*/g, (_m, p, c) => `${p}${GREEN}${c}${R}`);
|
|
18
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// ─── 状态外置 (Hermes 学习 #2): 每域一小 store + 纯函数 action ──────────────
|
|
2
|
+
// 目的: 状态不再散在 InkApp 单组件 useState 里, 而是集中到可订阅的 store,
|
|
3
|
+
// 输入/桥接 handler 变为 over store 的纯函数, 便于单测与热置换.
|
|
4
|
+
// 用 useSyncExternalStore 订阅 (React 18+, 与 nanostores 同款订阅语义).
|
|
5
|
+
import { useSyncExternalStore } from 'react';
|
|
6
|
+
export function createStore(initial) {
|
|
7
|
+
let state = initial;
|
|
8
|
+
const subs = new Set();
|
|
9
|
+
return {
|
|
10
|
+
get: () => state,
|
|
11
|
+
set: (v) => {
|
|
12
|
+
if (Object.is(v, state))
|
|
13
|
+
return;
|
|
14
|
+
state = v;
|
|
15
|
+
subs.forEach((cb) => cb());
|
|
16
|
+
},
|
|
17
|
+
subscribe: (cb) => {
|
|
18
|
+
subs.add(cb);
|
|
19
|
+
return () => { subs.delete(cb); };
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** 订阅 store, 值变化触发重渲染 (外部状态源统一入口) */
|
|
24
|
+
export function useStore(s) {
|
|
25
|
+
return useSyncExternalStore(s.subscribe, s.get, s.get);
|
|
26
|
+
}
|
|
27
|
+
// ── 域 store ────────────────────────────────────────────────────────────────
|
|
28
|
+
/** transcript: 消息列表 (agent/用户/系统行) — 供虚拟化/滚动/桥接读写 */
|
|
29
|
+
export const transcriptStore = createStore([]);
|
|
30
|
+
export const uiStore = createStore({ status: '', thinking: false, transient: null });
|
|
31
|
+
// ── 纯函数 action (bridge 与组件都走这里) ────────────────────────────────────
|
|
32
|
+
export function appendMsg(line) {
|
|
33
|
+
const c = transcriptStore.get();
|
|
34
|
+
transcriptStore.set([...c, line]);
|
|
35
|
+
}
|
|
36
|
+
export function replaceLastMsg(line) {
|
|
37
|
+
const c = transcriptStore.get();
|
|
38
|
+
if (c.length === 0) {
|
|
39
|
+
transcriptStore.set([line]);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const n = c.slice();
|
|
43
|
+
n[n.length - 1] = line;
|
|
44
|
+
transcriptStore.set(n);
|
|
45
|
+
}
|
|
46
|
+
/** 按内容标记原地替换一条 (占位框 → 完整内容); 未命中不改 */
|
|
47
|
+
export function replaceMarkerMsg(marker, line) {
|
|
48
|
+
const c = transcriptStore.get();
|
|
49
|
+
const i = c.indexOf(marker);
|
|
50
|
+
if (i < 0)
|
|
51
|
+
return;
|
|
52
|
+
const n = c.slice();
|
|
53
|
+
n[i] = line;
|
|
54
|
+
transcriptStore.set(n);
|
|
55
|
+
}
|
|
56
|
+
export function setUiStatus(status) {
|
|
57
|
+
uiStore.set({ ...uiStore.get(), status });
|
|
58
|
+
}
|
|
59
|
+
export function setUiThinking(thinking) {
|
|
60
|
+
uiStore.set({ ...uiStore.get(), thinking });
|
|
61
|
+
}
|
|
62
|
+
export function setUiTransient(v) {
|
|
63
|
+
uiStore.set({ ...uiStore.get(), transient: v === undefined ? null : v });
|
|
64
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* theme.ts — bolloon TUI 主题 token (唯一颜色事实源)
|
|
3
|
+
* 与 Web UI 一致: 主色 #c4d640, 文本 #d8d8c8, 警告 #f59e0b, 成功 #22c55e, 错误 #ef4444。
|
|
4
|
+
* 组件一律引用 THEME.*, 不再散落 hex 字面量。后续可扩展皮肤/深色切换。
|
|
5
|
+
*/
|
|
6
|
+
export const THEME = {
|
|
7
|
+
accent: '#c4d640', // 主色 (bolloon 绿)
|
|
8
|
+
text: '#d8d8c8', // 正文
|
|
9
|
+
muted: '#606058', // 次要/暗层
|
|
10
|
+
dim: '#909088', // 更暗
|
|
11
|
+
ok: '#22c55e', // 成功
|
|
12
|
+
error: '#ef4444', // 错误
|
|
13
|
+
warn: '#f59e0b', // 警告
|
|
14
|
+
border: '#3a3a36', // 暗描边
|
|
15
|
+
borderBright: '#8a8a7e', // 对话框边框提亮
|
|
16
|
+
};
|
|
17
|
+
/** '#c4d640' → '\x1b[38;2;196;214;64m' ANSI 前景色码 */
|
|
18
|
+
export function fg(hex) {
|
|
19
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
20
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
21
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
22
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timing.ts — TUI 时序常量 (原来散落魔法数字)
|
|
3
|
+
*/
|
|
4
|
+
export const DOUBLE_ESC_MS = 500; // 双击 Esc 退出窗口
|
|
5
|
+
export const STATUS_TICK_MS = 1000; // 状态栏刷新周期
|
|
6
|
+
export const THINK_FRAME_MS = 600; // 思考 kaomoji 帧间隔
|
|
7
|
+
export const LOAD_FRAME_MS = 100; // 启动仪表盘帧间隔
|
|
8
|
+
export const CHUNK_GUARD_DELAY_MS = 0; // 控制字符纠偏 setTimeout(0)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// ─── 占用槽 / widget host (Hermes 学习 #8) ────────────────────────────────
|
|
2
|
+
// 在 UI 右侧保留一个 rails 槽位 (占用槽), 供外部 widget 注册渲染.
|
|
3
|
+
// 触点: registerWidget / unregisterWidget / refreshWidgets / listWidgets.
|
|
4
|
+
// 默认无 widget 时槽位不占宽 (布局不变); 有 widget 时才预留右侧列.
|
|
5
|
+
// .mjs 热加载: hotReloadWidgets(dir) 监听目录, 动态 import 模块 (模块调用 registerWidget 注册).
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import { pathToFileURL } from 'url';
|
|
9
|
+
import { useSyncExternalStore } from 'react';
|
|
10
|
+
let widgets = new Map();
|
|
11
|
+
let cache = null; // getSnapshot 必须缓存同引用, 否则 useSyncExternalStore 无限重渲
|
|
12
|
+
const subs = new Set();
|
|
13
|
+
function rebuild() {
|
|
14
|
+
const out = {};
|
|
15
|
+
for (const [name, w] of widgets)
|
|
16
|
+
out[name] = w.text;
|
|
17
|
+
cache = out;
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
function emit() { cache = null; subs.forEach((cb) => cb()); }
|
|
21
|
+
export function getWidgets() {
|
|
22
|
+
if (!cache)
|
|
23
|
+
rebuild();
|
|
24
|
+
return cache;
|
|
25
|
+
}
|
|
26
|
+
/** 4 触点之一: 注册 (render 在注册时立即执行一次, 得 text) */
|
|
27
|
+
export function registerWidget(name, render) {
|
|
28
|
+
widgets.set(name, { name, render, text: render() });
|
|
29
|
+
emit();
|
|
30
|
+
}
|
|
31
|
+
export function unregisterWidget(name) {
|
|
32
|
+
if (widgets.delete(name))
|
|
33
|
+
emit();
|
|
34
|
+
}
|
|
35
|
+
/** 重新执行所有 widget 的 render (数据/间隔刷新) */
|
|
36
|
+
export function refreshWidgets() {
|
|
37
|
+
for (const [name, w] of widgets) {
|
|
38
|
+
try {
|
|
39
|
+
w.text = w.render();
|
|
40
|
+
}
|
|
41
|
+
catch { /* 单个 widget 渲染失败不致命 */ }
|
|
42
|
+
}
|
|
43
|
+
emit();
|
|
44
|
+
}
|
|
45
|
+
export function listWidgets() { return [...widgets.keys()]; }
|
|
46
|
+
/** 仅测试用: 清空所有 widget */
|
|
47
|
+
export function resetWidgetsForTest() { widgets = new Map(); emit(); }
|
|
48
|
+
export function subscribeWidgets(cb) { subs.add(cb); return () => { subs.delete(cb); }; }
|
|
49
|
+
/** React 订阅: 返回 {name: text} (槽位渲染用) */
|
|
50
|
+
export function useWidgets() {
|
|
51
|
+
return useSyncExternalStore(subscribeWidgets, getWidgets, getWidgets);
|
|
52
|
+
}
|
|
53
|
+
/** .mjs 热加载: 监听 dir, 新增/变更的 .mjs 直接 import (模块内调 registerWidget); 失败静默 */
|
|
54
|
+
export function hotReloadWidgets(dir) {
|
|
55
|
+
if (!fs.existsSync(dir))
|
|
56
|
+
return null;
|
|
57
|
+
const loaded = new Set();
|
|
58
|
+
const load = (f) => {
|
|
59
|
+
if (f.endsWith('.mjs') && !loaded.has(f)) {
|
|
60
|
+
loaded.add(f);
|
|
61
|
+
import(pathToFileURL(path.join(dir, f)).href).catch(() => { });
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
for (const f of fs.readdirSync(dir))
|
|
65
|
+
load(f);
|
|
66
|
+
return setInterval(() => { for (const f of fs.readdirSync(dir))
|
|
67
|
+
load(f); }, 3000);
|
|
68
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { sha512 } from '@noble/hashes/sha2.js';
|
|
|
6
6
|
import * as fs from 'fs/promises';
|
|
7
7
|
import { existsSync, mkdirSync } from 'fs';
|
|
8
8
|
import * as path from 'path';
|
|
9
|
-
import { spawn } from 'child_process';
|
|
9
|
+
import { spawn, execSync } from 'child_process';
|
|
10
10
|
import * as os from 'os';
|
|
11
11
|
import { documentReader } from './documents/reader.js';
|
|
12
12
|
import { initMinimax } from './constraints/index.js';
|
|
@@ -15,8 +15,8 @@ import { createSubAgentManager } from './agents/subagent-manager.js';
|
|
|
15
15
|
import { getGlobalSharedContext } from './social/global-shared-context.js';
|
|
16
16
|
import { createBollharnessIntegration } from './bollharness-integration/index.js';
|
|
17
17
|
import * as readline from 'readline';
|
|
18
|
-
import { printBanner, renderDialog, renderUserMessage, renderAgentMessage, renderMessageBox, renderToolCallListItem, termWidth } from './cli/loading-tui.js';
|
|
19
|
-
import { startInk, stopInk, inkAppendLine as appendLine, inkSetStatus, inkSetThinking, inkSetTransient } from './cli/ink-app.js';
|
|
18
|
+
import { printBanner, renderDialog, renderUserMessage, renderAgentMessage, renderMessageBox, renderToolCallListItem, termWidth, ROBOT_HEAD, BOLLOON_BANNER, dispWidth } from './cli/loading-tui.js';
|
|
19
|
+
import { startInk, stopInk, inkAppendLine as appendLine, inkReplaceMatchingLine, inkSetStatus, inkSetThinking, inkSetTransient } from './cli/ink-app.js';
|
|
20
20
|
// 启动自动检查更新:后台、节流、检测到新版本自动安装(可被 --no-update / BOLLOON_SKIP_UPDATE 关闭)
|
|
21
21
|
import { createRequire } from 'module';
|
|
22
22
|
const _require = createRequire(import.meta.url);
|
|
@@ -497,25 +497,169 @@ function statusBarLine() {
|
|
|
497
497
|
const usage = getCliCtxUsage();
|
|
498
498
|
return `${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ ${C_ACCENT}${dur}${RESET} ${C_DIM}│${RESET} ${buildContextBar(usage)}`;
|
|
499
499
|
}
|
|
500
|
+
/**
|
|
501
|
+
* 启动会话面板 — 按类别展示启动加载的 skills (真实类别 = 目录名去掉 frontmatter.name 后缀),
|
|
502
|
+
* 每类一行 (前 6 名 + '+N more'), 结尾 totals; skills/tools 并行 (各 2.5s 预算), 失败静默.
|
|
503
|
+
*/
|
|
504
|
+
/** 启动面板框: BOLLOON 字标 logo 顶部居中 → 下方两栏 = face 艺术字(左) + 加载内容(右) */
|
|
505
|
+
function buildBootBox(face, banner, rest) {
|
|
506
|
+
const faceW = Math.max(1, ...face.map((l) => dispWidth(l)));
|
|
507
|
+
const bannerMax = Math.max(1, ...banner.map((l) => dispWidth(l)));
|
|
508
|
+
const restMax = Math.max(1, ...rest.map((l) => dispWidth(l)));
|
|
509
|
+
const gap = 4;
|
|
510
|
+
const twoColW = faceW + gap + restMax;
|
|
511
|
+
const contentW = Math.max(bannerMax + 8, twoColW);
|
|
512
|
+
const center = (rows) => rows.map((l) => ' '.repeat(Math.max(0, Math.floor((contentW - dispWidth(l)) / 2))) + l);
|
|
513
|
+
const n = rest.length;
|
|
514
|
+
const fStart = Math.max(0, Math.floor((n - face.length) / 2)); // face 列对内容垂直居中 = 等高
|
|
515
|
+
const twoCol = rest.map((r, i) => {
|
|
516
|
+
const f = (i >= fStart && i < fStart + face.length) ? face[i - fStart] : null;
|
|
517
|
+
const fpart = f ? `${f}${' '.repeat(Math.max(0, faceW - dispWidth(f)) + gap)}` : ' '.repeat(faceW + gap);
|
|
518
|
+
return fpart + r;
|
|
519
|
+
});
|
|
520
|
+
const body = [...center(banner), '', ...twoCol];
|
|
521
|
+
return renderMessageBox({ title: '🚀 Bolloon · 启动面板', body: body.join('\n'), color: C_ACCENT, maxLines: 0 });
|
|
522
|
+
}
|
|
523
|
+
async function bootPanel(boot) {
|
|
524
|
+
const sub = []; // tools / MCP (Promise.all 里填充, 最后统一排到类别下方)
|
|
525
|
+
const catNames = new Map();
|
|
526
|
+
await Promise.all([
|
|
527
|
+
(async () => {
|
|
528
|
+
try {
|
|
529
|
+
// 真实类别 = 目录名前缀去掉技能名后缀 (SKILL.md 无 category 字段, 但 frontmatter.name 是真名:
|
|
530
|
+
// software-development-bolloon-development / name=bolloon-development → software-development)
|
|
531
|
+
const { loadSkillsDir, defaultSkillPaths } = await import('./agents/skill-loader.js');
|
|
532
|
+
const pushCat = (cat, name) => {
|
|
533
|
+
const arr = catNames.get(cat) || [];
|
|
534
|
+
if (!arr.includes(name))
|
|
535
|
+
arr.push(name);
|
|
536
|
+
catNames.set(cat, arr);
|
|
537
|
+
};
|
|
538
|
+
for (const root of defaultSkillPaths()) {
|
|
539
|
+
const metas = await loadSkillsDir(root);
|
|
540
|
+
for (const m of metas) {
|
|
541
|
+
if (m.status === 'archived')
|
|
542
|
+
continue;
|
|
543
|
+
const dir = m.sourcePath ? path.basename(path.dirname(m.sourcePath)) : '';
|
|
544
|
+
const nm = m.name || '';
|
|
545
|
+
let cat = dir;
|
|
546
|
+
if (dir && nm && dir.endsWith(nm)) {
|
|
547
|
+
const pre = dir.slice(0, dir.length - nm.length).replace(/-+$/, '');
|
|
548
|
+
if (pre)
|
|
549
|
+
cat = pre;
|
|
550
|
+
}
|
|
551
|
+
pushCat(cat || 'other', nm || dir);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
catch { /* 省略 */ }
|
|
556
|
+
})(),
|
|
557
|
+
(async () => {
|
|
558
|
+
try {
|
|
559
|
+
const a = await Promise.race([
|
|
560
|
+
getAgent().catch(() => null),
|
|
561
|
+
new Promise((res) => setTimeout(() => res(null), 2500)),
|
|
562
|
+
]);
|
|
563
|
+
const tools = a && typeof a.getToolList === 'function' ? a.getToolList() : null;
|
|
564
|
+
if (tools && tools.length > 0)
|
|
565
|
+
sub.push(`🔧 ${tools.length} tools`);
|
|
566
|
+
}
|
|
567
|
+
catch { /* 省略 */ }
|
|
568
|
+
})(),
|
|
569
|
+
(async () => {
|
|
570
|
+
try {
|
|
571
|
+
const { getAdapterStatus } = await import('./pi-ecosystem-mcp/index.js');
|
|
572
|
+
const st = getAdapterStatus();
|
|
573
|
+
if (st.initialized && st.serverCount > 0)
|
|
574
|
+
sub.push(`🔌 MCP ${st.serverCount} 服务器 · ${st.toolCount} tools`);
|
|
575
|
+
}
|
|
576
|
+
catch { /* 省略 */ }
|
|
577
|
+
})(),
|
|
578
|
+
]);
|
|
579
|
+
// 栈式布局: face 艺术字居中 → BOLLOON 字标 logo 在其下 → 内容左对齐
|
|
580
|
+
const art = ROBOT_HEAD;
|
|
581
|
+
const banner = BOLLOON_BANNER.split('\n');
|
|
582
|
+
// 头: 目录 / 模型 / Session (预先加载信息)
|
|
583
|
+
const rest = [];
|
|
584
|
+
if (boot.dir)
|
|
585
|
+
rest.push(`📁 ${boot.dir}`);
|
|
586
|
+
if (boot.model)
|
|
587
|
+
rest.push(`模型 ${boot.model}`);
|
|
588
|
+
if (boot.session)
|
|
589
|
+
rest.push(`Session: ${boot.session}`);
|
|
590
|
+
rest.push('');
|
|
591
|
+
// 类别行 (全部展开, 不截断类别; 每类列前 8 名 + '+N more') — 单一实例归 other
|
|
592
|
+
const normalized = new Map();
|
|
593
|
+
for (const [cat, arr] of catNames) {
|
|
594
|
+
if (arr.length === 1) {
|
|
595
|
+
const o = normalized.get('other') || [];
|
|
596
|
+
o.push(...arr);
|
|
597
|
+
normalized.set('other', o);
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
normalized.set(cat, (normalized.get(cat) || []).concat(arr));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const sorted = [...normalized.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
604
|
+
const total = sorted.reduce((s, [, arr]) => s + arr.length, 0);
|
|
605
|
+
for (const [cat, arr] of sorted) {
|
|
606
|
+
const shown = arr.slice(0, 8);
|
|
607
|
+
const more = arr.length > shown.length ? `, +${arr.length - shown.length} more` : '';
|
|
608
|
+
rest.push(`${cat}: ${shown.join(', ')}${more}`);
|
|
609
|
+
}
|
|
610
|
+
rest.push(`⚡ ${total} skills · ${sorted.length} 类`, '');
|
|
611
|
+
if (sub.length)
|
|
612
|
+
rest.push(...sub, '');
|
|
613
|
+
// 自动整理 (经验/技能整理心跳) 模式并入面板
|
|
614
|
+
rest.push(`🧹 经验自动整理: 启动后每 30min 一次`);
|
|
615
|
+
try {
|
|
616
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', { encoding: 'utf8', timeout: 1500 }).trim();
|
|
617
|
+
if (branch)
|
|
618
|
+
rest.push(`⎇ ${branch}`);
|
|
619
|
+
}
|
|
620
|
+
catch { /* 非 git 目录省略 */ }
|
|
621
|
+
try {
|
|
622
|
+
rest.push(new Date().toLocaleTimeString('zh-CN', { hour12: false }));
|
|
623
|
+
}
|
|
624
|
+
catch { /* 忽略 */ }
|
|
625
|
+
if (rest.length <= 3)
|
|
626
|
+
return null;
|
|
627
|
+
// 启动面板框: BOLLOON 字标 logo 顶部居中 → 下方两栏 = face 艺术字(左) + skills/信息(右)
|
|
628
|
+
return buildBootBox(art, banner, rest);
|
|
629
|
+
}
|
|
500
630
|
async function startCLI(commReady) {
|
|
501
631
|
isRunning = true;
|
|
502
632
|
// 2026-09-08 加速启动: P2P 后台就绪, UI 直接渲染不阻塞 — comm 就绪前为 null,
|
|
503
633
|
// 内部用法全空安全 (P2P 功能自动降级, 就绪后立即可用)
|
|
504
634
|
let comm = null;
|
|
505
635
|
commReady.then((c) => { comm = c; }).catch(() => { });
|
|
506
|
-
// CLI 模式下静音所有 console.log/warn
|
|
507
|
-
// (Ink 用自己的 render 引擎, console
|
|
636
|
+
// CLI 模式下静音所有 console.log/warn/info/debug
|
|
637
|
+
// (Ink 用自己的 render 引擎, console 输出会污染终端)
|
|
508
638
|
console.log = () => { };
|
|
509
639
|
console.warn = () => { };
|
|
510
|
-
|
|
640
|
+
console.info = () => { };
|
|
641
|
+
console.debug = () => { };
|
|
642
|
+
// 过滤 process.stdout/stderr.write — 丢弃启动期 SDK/后台日志
|
|
643
|
+
// (ISO 时间戳前缀如 `2026-09-08T...Z [info]:` 或被 [info]/[warn]/[error] 标记的行)
|
|
511
644
|
const _origStdout = process.stdout.write.bind(process.stdout);
|
|
512
|
-
process.
|
|
645
|
+
const _origStderr = process.stderr.write.bind(process.stderr);
|
|
646
|
+
const isLogLine = (line) => {
|
|
647
|
+
const t = line.trimStart();
|
|
648
|
+
return t.startsWith('[') || /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(t) || /\[\s*(info|warn|error|debug|log)\s*\]/.test(t)
|
|
649
|
+
// Kubo/ipfs 启动噪声: "Use 'ipfs init --help'..." / "ipfs daemon is running..." 提示行无时间戳, 一并丢弃
|
|
650
|
+
|| /ipfs init --help|ipfs daemon is running|please stop it to run this command/i.test(t);
|
|
651
|
+
};
|
|
652
|
+
const wrap = (orig) => (chunk, ...rest) => {
|
|
513
653
|
const s = typeof chunk === 'string' ? chunk : String(chunk);
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
654
|
+
if (!isLogLine(s))
|
|
655
|
+
return orig(chunk, ...rest);
|
|
656
|
+
const keep = s.split('\n').filter((l) => !isLogLine(l)).join('\n');
|
|
657
|
+
if (keep)
|
|
658
|
+
orig(keep, ...rest);
|
|
659
|
+
return true;
|
|
660
|
+
};
|
|
661
|
+
process.stdout.write = wrap(_origStdout);
|
|
662
|
+
process.stderr.write = wrap(_origStderr);
|
|
519
663
|
let peerCount = 0;
|
|
520
664
|
void commReady.then((c) => { try {
|
|
521
665
|
if (c)
|
|
@@ -594,6 +738,13 @@ async function startCLI(commReady) {
|
|
|
594
738
|
}
|
|
595
739
|
catch { /* 降级: getCliCtxUsage 返回 0/1M */ }
|
|
596
740
|
const initialStatus = `${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ 0s${C_DIM} │${RESET} ${buildContextBar(getCliCtxUsage())}`;
|
|
741
|
+
// 2026-09-08 (leo 规格): 图标下元信息层数据 — 目录(home→~) / 模型 / Session id (Hermes 风格: YYYYMMDD_HHMMSS_xxxx)
|
|
742
|
+
const bootDirShort = process.cwd().replace(os.homedir(), '~');
|
|
743
|
+
const bootSessionId = (() => {
|
|
744
|
+
const d = new Date(cliStartTime);
|
|
745
|
+
const p = (n, l = 2) => String(n).padStart(l, '0');
|
|
746
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}_${Math.random().toString(16).slice(2, 8)}`;
|
|
747
|
+
})();
|
|
597
748
|
startInk((text) => { processInput(text, comm); }, initialStatus, getStatus);
|
|
598
749
|
// 2026-08-10: 自动整理心跳 (CLI 侧, 与社交心跳并列) — 启动后立即"固定看一下 skills view"
|
|
599
750
|
// (扫描遗留 skills), 之后按周期 (默认 30min, env BOLLOON_ORGANIZE_HEARTBEAT_MS) 完整进化经验.
|
|
@@ -683,6 +834,18 @@ async function startCLI(commReady) {
|
|
|
683
834
|
setTimeout(() => { cronScheduler.tick().catch(() => { }); }, 15_000);
|
|
684
835
|
}
|
|
685
836
|
catch { /* cron 调度启动失败不阻塞 CLI */ }
|
|
837
|
+
// 启动会话面板 (大框): 栈式 = face 艺术字居中 + BOLLOON 字标 logo 在其下 + 预设信息(skills/工具/模型/目录/Session/分支/时间)
|
|
838
|
+
// 先立即渲染「艺术字 + logo + 正在加载...」, bootPanel 就绪后 inkReplaceMatchingLine 按标记原位替换为完整内容
|
|
839
|
+
// (用匹配替换而非 replaceLast — P2P/连接消息可能先于 bootPanel 追加, replaceLast 会覆盖错一条)
|
|
840
|
+
const bootBox = buildBootBox(ROBOT_HEAD, BOLLOON_BANNER.split('\n'), [
|
|
841
|
+
`${bootDirShort} · ${(cliModelName && cliModelName !== '…') ? cliModelName : ''} · Session: ${bootSessionId}`,
|
|
842
|
+
'',
|
|
843
|
+
'⟳ 正在加载技能 / 工具...',
|
|
844
|
+
]);
|
|
845
|
+
appendLine(bootBox);
|
|
846
|
+
void bootPanel({ dir: bootDirShort, model: (cliModelName && cliModelName !== '…') ? cliModelName : undefined, session: bootSessionId })
|
|
847
|
+
.then((box) => { if (box)
|
|
848
|
+
inkReplaceMatchingLine(bootBox, box); }).catch(() => { });
|
|
686
849
|
// Wait on a promise that resolves on Ctrl+C / 双击 Esc
|
|
687
850
|
// (ink-app 的 requestExit 调 __inkRequestExit → resolve, 清理后 process.exit)
|
|
688
851
|
let cliExitResolve = () => { };
|