@bolloon/bolloon-agent 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/agents/agent-lang.js +101 -0
- package/dist/agents/agent-manifest-protocol.js +4 -0
- package/dist/agents/execution-supervisor.js +87 -5
- package/dist/agents/gateway-group.js +44 -3
- package/dist/agents/model-policy.js +651 -0
- package/dist/agents/pi-sdk-tools.js +84 -33
- package/dist/agents/pi-sdk.js +184 -31
- package/dist/agents/run-store.js +53 -0
- package/dist/agents/task/task-runner.js +8 -1
- package/dist/agents/workflow-pivot-loop.js +22 -13
- package/dist/cli/clipboard.js +65 -0
- package/dist/cli/ink-app.js +145 -17
- package/dist/cli/log-gate.js +386 -0
- package/dist/cli/mention-data.js +2 -0
- package/dist/cli/model-selector.js +538 -0
- package/dist/cli/reply-hygiene.js +54 -0
- package/dist/cli/setup-wizard.js +686 -69
- package/dist/cli/startup-notice.js +160 -0
- package/dist/cli/stores.js +22 -1
- package/dist/cli/theme.js +49 -0
- package/dist/cli/tui-select.js +970 -0
- package/dist/cli-entry.js +125 -18
- package/dist/efficode/compress.js +78 -0
- package/dist/efficode/crc32.js +38 -0
- package/dist/efficode/did.js +66 -0
- package/dist/efficode/index.js +24 -0
- package/dist/efficode/lz77.js +170 -0
- package/dist/efficode/negotiate.js +187 -0
- package/dist/efficode/ops.js +87 -0
- package/dist/efficode/packet.js +271 -0
- package/dist/efficode/types.js +45 -0
- package/dist/electron-build/agents/agent-manifest-protocol.js +4 -0
- package/dist/electron-build/agents/agent-manifest-protocol.js.map +1 -1
- package/dist/electron-build/agents/execution-supervisor.js +86 -4
- package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
- package/dist/electron-build/agents/goal-store.js.map +1 -1
- package/dist/electron-build/agents/model-policy.js +706 -0
- package/dist/electron-build/agents/model-policy.js.map +1 -0
- package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
- package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk.js +185 -31
- package/dist/electron-build/agents/pi-sdk.js.map +1 -1
- package/dist/electron-build/agents/run-store.js +55 -1
- package/dist/electron-build/agents/run-store.js.map +1 -1
- package/dist/electron-build/agents/task/task-runner.js +8 -1
- package/dist/electron-build/agents/task/task-runner.js.map +1 -1
- package/dist/electron-build/agents/workflow-pivot-loop.js +22 -13
- package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
- package/dist/electron-build/llm/config-store.js +146 -6
- package/dist/electron-build/llm/config-store.js.map +1 -1
- package/dist/electron-build/llm/connection-probe.js +764 -0
- package/dist/electron-build/llm/connection-probe.js.map +1 -0
- package/dist/electron-build/llm/custom-provider-store.js +272 -0
- package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
- package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
- package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
- package/dist/electron-build/llm/model-catalog.js +778 -0
- package/dist/electron-build/llm/model-catalog.js.map +1 -0
- package/dist/electron-build/llm/model-discovery.js +1202 -0
- package/dist/electron-build/llm/model-discovery.js.map +1 -0
- package/dist/electron-build/llm/model-selection.js +1315 -0
- package/dist/electron-build/llm/model-selection.js.map +1 -0
- package/dist/electron-build/llm/pi-ai.js +751 -55
- package/dist/electron-build/llm/pi-ai.js.map +1 -1
- package/dist/electron-build/llm/provider-catalog.js +969 -0
- package/dist/electron-build/llm/provider-catalog.js.map +1 -0
- package/dist/electron-build/llm/provider-registry.js +950 -0
- package/dist/electron-build/llm/provider-registry.js.map +1 -0
- package/dist/electron-build/llm/tool-name.js +266 -0
- package/dist/electron-build/llm/tool-name.js.map +1 -0
- package/dist/electron-build/utils/dual-source-facts.js +236 -0
- package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
- package/dist/electron-build/utils/dual-source.js +32 -156
- package/dist/electron-build/utils/dual-source.js.map +1 -1
- package/dist/electron-build/utils/update-manager.js +22 -72
- package/dist/electron-build/utils/update-manager.js.map +1 -1
- package/dist/electron-build/utils/update-state.js +2 -19
- package/dist/electron-build/utils/update-state.js.map +1 -1
- package/dist/electron-build/utils/version-identity.js +193 -0
- package/dist/electron-build/utils/version-identity.js.map +1 -0
- package/dist/electron-build/utils/version-info.js +47 -101
- package/dist/electron-build/utils/version-info.js.map +1 -1
- package/dist/index.js +282 -96
- package/dist/ios/bolloon-web.json +10 -0
- package/dist/ios/mobile-contacts.js +44 -11
- package/dist/ios/mobile-core.js +1723 -84
- package/dist/ios/mobile-tasks.js +339 -0
- package/dist/ios/mobile-update.js +1442 -0
- package/dist/ios/mobile.js +308 -35
- package/dist/ios/routes-llm-config.js +344 -52
- package/dist/ios/routes-mobile-tasks.js +353 -0
- package/dist/ios/server.js +163 -4
- package/dist/ios/sw.js +1 -1
- package/dist/llm/config-store.js +146 -6
- package/dist/llm/connection-probe.js +744 -0
- package/dist/llm/custom-provider-store.js +224 -0
- package/dist/llm/data/provider-catalog-baked.js +19 -0
- package/dist/llm/model-catalog.js +717 -0
- package/dist/llm/model-discovery.js +1137 -0
- package/dist/llm/model-selection.js +1241 -0
- package/dist/llm/pi-ai.js +732 -54
- package/dist/llm/provider-catalog.js +898 -0
- package/dist/llm/provider-registry.js +917 -0
- package/dist/llm/tool-name.js +220 -0
- package/dist/setup/onboard.js +34 -8
- package/dist/utils/dual-source-facts.js +223 -0
- package/dist/utils/dual-source.js +12 -141
- package/dist/utils/update-manager.js +16 -59
- package/dist/utils/update-state.js +6 -18
- package/dist/utils/version-identity.js +176 -0
- package/dist/utils/version-info.js +9 -78
- package/dist/web/bolloon-web.json +10 -0
- package/dist/web/mobile-core.js +1721 -84
- package/dist/web/mobile-update.js +1442 -0
- package/dist/web/mobile.js +372 -35
- package/dist/web/routes-llm-config.js +344 -52
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/scripts/build-web.ts +36 -0
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* - Token budget awareness
|
|
14
14
|
*/
|
|
15
15
|
import { detectRepeatingCalls, toolCallArgsHash } from './loop-review.js';
|
|
16
|
+
// 2026-09-26: 回程派发还原 (LLM 回吐的 API 名 → 注册表真名; 净化在 pi-ai.ts 唯一边界)
|
|
17
|
+
import { resolveApiToolName } from '../llm/tool-name.js';
|
|
16
18
|
/**
|
|
17
19
|
* 2026-08-11 (借鉴 Hermes `_canonicalize_tool_call_arguments`): 工具参数规范化 —
|
|
18
20
|
* LLM 返回的 arguments 常带尾随散文/尾逗号/代码围栏, 直接 JSON.parse 失败会丢整个工具调用.
|
|
@@ -234,7 +236,8 @@ export class WorkflowPivotLoop {
|
|
|
234
236
|
const fn = tc?.function;
|
|
235
237
|
if (!fn || !fn.name)
|
|
236
238
|
continue;
|
|
237
|
-
|
|
239
|
+
// 2026-09-26: 回程还原 — 原生 tool_calls 里的名字是净化后的 API 名
|
|
240
|
+
const name = resolveApiToolName(fn.name);
|
|
238
241
|
if (!this.tools.has(name)) {
|
|
239
242
|
this.pushContinuationHint(`工具 ${name} 未注册/不存在, 调用已跳过 — 请换用已提供的工具`);
|
|
240
243
|
continue;
|
|
@@ -326,7 +329,9 @@ export class WorkflowPivotLoop {
|
|
|
326
329
|
// 2026-08-02: 传原生 OpenAI tools — deepseek 返回结构化 tool_calls,
|
|
327
330
|
// UI 才能显示真实的工具执行 step (之前靠文本 JSON 猜格式, LLM 编造 result)
|
|
328
331
|
const openAITools = this.buildOpenAITools();
|
|
329
|
-
|
|
332
|
+
// 2026-09-28: pivot loop 就是主对话循环 → purpose='main-agent'
|
|
333
|
+
// (完整 IMMUTABLE PREFIX + 工具全集 + CURRENT TURN; 也只有它带 cache_prompt)
|
|
334
|
+
const llmResponse = await llm.chat(context, headerForThisIter, signal, openAITools, 'main-agent', 'pivot-loop');
|
|
330
335
|
const reply = (llmResponse.reply || '').trim();
|
|
331
336
|
this.vlog(`[pivot] iter=${this.state.iteration} LLM took=${Date.now() - t0}ms reply=${reply.length} nativeToolCalls=${llmResponse.toolCalls?.length ?? 0} head=${reply.substring(0, 80).replace(/\n/g, ' ')}`);
|
|
332
337
|
this.emit({ type: 'token', content: reply });
|
|
@@ -613,11 +618,12 @@ export class WorkflowPivotLoop {
|
|
|
613
618
|
while ((match = toolUseRe.exec(content)) !== null) {
|
|
614
619
|
try {
|
|
615
620
|
const obj = JSON.parse(match[1]);
|
|
616
|
-
|
|
621
|
+
const realName = obj && obj.name ? resolveApiToolName(String(obj.name)) : '';
|
|
622
|
+
if (obj && realName && this.tools.has(realName)) {
|
|
617
623
|
const args = this.normalizeArgs(canonicalizeToolCallArguments(obj.arguments));
|
|
618
|
-
pending.push({ name:
|
|
624
|
+
pending.push({ name: realName, args, description: '', parameters: {} });
|
|
619
625
|
}
|
|
620
|
-
else if (obj && obj.name && !this.tools.has(
|
|
626
|
+
else if (obj && obj.name && !this.tools.has(realName)) {
|
|
621
627
|
this.pushContinuationHint(`工具 ${obj.name} 未注册/不存在, 调用已跳过 — 请换用已提供的工具`);
|
|
622
628
|
}
|
|
623
629
|
}
|
|
@@ -634,7 +640,8 @@ export class WorkflowPivotLoop {
|
|
|
634
640
|
const invokeRe = /<invoke\s+name="(\w+)"\s*>([\s\S]*?)<\/invoke>/g;
|
|
635
641
|
let im;
|
|
636
642
|
while ((im = invokeRe.exec(block)) !== null) {
|
|
637
|
-
|
|
643
|
+
// 2026-09-26: 回程还原 — LLM 可能回吐的是净化后的 API 名
|
|
644
|
+
const name = resolveApiToolName(im[1]);
|
|
638
645
|
if (!this.tools.has(name))
|
|
639
646
|
continue;
|
|
640
647
|
// 抓 <parameter name="k">v</parameter> 列表
|
|
@@ -642,7 +649,7 @@ export class WorkflowPivotLoop {
|
|
|
642
649
|
const paramRe = /<parameter\s+name="(\w+)"\s*>([\s\S]*?)<\/parameter>/g;
|
|
643
650
|
let pm;
|
|
644
651
|
while ((pm = paramRe.exec(im[2])) !== null) {
|
|
645
|
-
args[pm[1]] = pm[2].trim().replace(/^["']|['
|
|
652
|
+
args[pm[1]] = pm[2].trim().replace(/^["']|["']$/g, '');
|
|
646
653
|
}
|
|
647
654
|
// 避免重复添加
|
|
648
655
|
if (!pending.some(p => p.name === name)) {
|
|
@@ -659,7 +666,8 @@ export class WorkflowPivotLoop {
|
|
|
659
666
|
const invokeRe = /<invoke\s+name="(\w+)"\s*>([\s\S]*?)<\/invoke>/g;
|
|
660
667
|
let im;
|
|
661
668
|
while ((im = invokeRe.exec(block)) !== null) {
|
|
662
|
-
|
|
669
|
+
// 2026-09-26: 回程还原 — LLM 可能回吐的是净化后的 API 名
|
|
670
|
+
const name = resolveApiToolName(im[1]);
|
|
663
671
|
if (!this.tools.has(name))
|
|
664
672
|
continue;
|
|
665
673
|
const args = {};
|
|
@@ -676,7 +684,7 @@ export class WorkflowPivotLoop {
|
|
|
676
684
|
// Pattern 1: Chinese format "调用工具: tool_name(args)"
|
|
677
685
|
const pattern1 = /调用工具[::]\s*(\w+)\s*\(([^)]*)\)/g;
|
|
678
686
|
while ((match = pattern1.exec(content)) !== null) {
|
|
679
|
-
const name = match[1];
|
|
687
|
+
const name = resolveApiToolName(match[1]);
|
|
680
688
|
const argsStr = match[2];
|
|
681
689
|
const args = this.parseArgs(argsStr);
|
|
682
690
|
if (this.tools.has(name)) {
|
|
@@ -686,7 +694,7 @@ export class WorkflowPivotLoop {
|
|
|
686
694
|
// Pattern 2: tool_name(args) format
|
|
687
695
|
const pattern2 = /(\w+)\s*\(\s*([^)]*)\s*\)/g;
|
|
688
696
|
while ((match = pattern2.exec(content)) !== null) {
|
|
689
|
-
const name = match[1];
|
|
697
|
+
const name = resolveApiToolName(match[1]);
|
|
690
698
|
const argsStr = match[2];
|
|
691
699
|
// Skip if already matched or doesn't look like a tool call
|
|
692
700
|
if (pending.some(p => p.name === name))
|
|
@@ -703,8 +711,9 @@ export class WorkflowPivotLoop {
|
|
|
703
711
|
const parsed = JSON.parse(jsonMatch[0]);
|
|
704
712
|
if (Array.isArray(parsed.tool_calls)) {
|
|
705
713
|
for (const tc of parsed.tool_calls) {
|
|
706
|
-
|
|
707
|
-
|
|
714
|
+
const realName = resolveApiToolName(String(tc?.name ?? ''));
|
|
715
|
+
if (this.tools.has(realName)) {
|
|
716
|
+
pending.push({ name: realName, args: tc.args || {}, description: '', parameters: {} });
|
|
708
717
|
}
|
|
709
718
|
}
|
|
710
719
|
}
|
|
@@ -720,7 +729,7 @@ export class WorkflowPivotLoop {
|
|
|
720
729
|
const singleJsonRe = /\{\s*"name"\s*:\s*"(\w+)"\s*,\s*"(?:arguments|input)"\s*:\s*(\{[\s\S]*?\})\s*\}/g;
|
|
721
730
|
let singleMatch;
|
|
722
731
|
while ((singleMatch = singleJsonRe.exec(content)) !== null) {
|
|
723
|
-
const name = singleMatch[1];
|
|
732
|
+
const name = resolveApiToolName(singleMatch[1]);
|
|
724
733
|
if (pending.some(p => p.name === name))
|
|
725
734
|
continue;
|
|
726
735
|
if (!this.tools.has(name))
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clipboard.ts — 把文本真写进**系统剪贴板** (2026-09-27)
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要它
|
|
5
|
+
*
|
|
6
|
+
* 面板是 Ink 画的**一屏自绘帧**: 每帧都是 `eraseLines` + 重画。终端里的鼠标框选靠终端自己维护,
|
|
7
|
+
* 而"每秒重画一帧"会不断打断正在进行的框选 —— 于是"想抄一段回复"变成了撞运气。
|
|
8
|
+
* 真终端字节证据 (`scripts/verify-cli-panel.ts` 抓的那一份):
|
|
9
|
+
* · `ESC[2J ESC[3J ESC[H` (**清屏 + 清回滚缓冲**) 在 30s 内出现 24 次;
|
|
10
|
+
* · 每一次都来自 Ink 的 `renderInteractiveFrame` (`shouldClearTerminalForFrame`: 帧高缩了/上一帧是别的形状)。
|
|
11
|
+
* 这既解释了"选不中", 也解释了"往上滚看不到更早的内容"。
|
|
12
|
+
*
|
|
13
|
+
* 所以复制走**两条腿**:
|
|
14
|
+
* ① 结构上少打断: 版面固定成"整屏高" + 跟随暂停时**完全不重画** (见 `ink-app.tsx`) → 帧不再乱清;
|
|
15
|
+
* ② 一条**不靠鼠标**的显式通路: `/copy` —— 把最近一条/整段回复交给系统剪贴板工具。
|
|
16
|
+
* 能用 `pbcopy` 就不假装"已经复制到剪贴板了"。
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from 'child_process';
|
|
19
|
+
/** 剪贴板工具按平台挑 (第一个能跑的胜出); 环境变量 `BOLLOON_CLIPBOARD_CMD` 可显式覆盖 (验收门用) */
|
|
20
|
+
export function clipboardCommand(env = process.env) {
|
|
21
|
+
const override = (env.BOLLOON_CLIPBOARD_CMD ?? '').trim();
|
|
22
|
+
if (override) {
|
|
23
|
+
const parts = override.split(/\s+/).filter(Boolean);
|
|
24
|
+
if (parts.length)
|
|
25
|
+
return { cmd: parts[0], args: parts.slice(1) };
|
|
26
|
+
}
|
|
27
|
+
switch (process.platform) {
|
|
28
|
+
case 'darwin': return { cmd: 'pbcopy', args: [] };
|
|
29
|
+
case 'win32': return { cmd: 'clip', args: [] };
|
|
30
|
+
default:
|
|
31
|
+
if (env.WAYLAND_DISPLAY)
|
|
32
|
+
return { cmd: 'wl-copy', args: [] };
|
|
33
|
+
if (env.DISPLAY)
|
|
34
|
+
return { cmd: 'xclip', args: ['-selection', 'clipboard'] };
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** 剪贴板工具名 (报告/界面里只说工具名, 不打印被复制的内容) */
|
|
39
|
+
export function clipboardToolName(env = process.env) {
|
|
40
|
+
const c = clipboardCommand(env);
|
|
41
|
+
return c ? c.cmd : null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 真写剪贴板 (同步: 这条命令要立刻给出"成没成", 不能 fire-and-forget)。
|
|
45
|
+
* 返回值只含**长度与工具名** —— 文本内容绝不回显/落日志 (回复里可能有 key/私密内容)。
|
|
46
|
+
*/
|
|
47
|
+
export function clipboardWrite(text, env = process.env) {
|
|
48
|
+
const chars = String(text ?? '').length;
|
|
49
|
+
const c = clipboardCommand(env);
|
|
50
|
+
if (!c) {
|
|
51
|
+
return { ok: false, chars, error: '这个平台没有可用的剪贴板命令 (macOS: pbcopy / Windows: clip / Linux: wl-copy 或 xclip)' };
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const r = spawnSync(c.cmd, c.args, { input: String(text ?? ''), encoding: 'utf8', timeout: 5000 });
|
|
55
|
+
if (r.error)
|
|
56
|
+
return { ok: false, chars, tool: c.cmd, error: `调用 ${c.cmd} 失败: ${r.error.message}` };
|
|
57
|
+
if (r.status !== 0) {
|
|
58
|
+
return { ok: false, chars, tool: c.cmd, error: `${c.cmd} 退出码 ${r.status}${r.stderr ? `: ${String(r.stderr).slice(0, 120)}` : ''}` };
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, chars, tool: c.cmd };
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
return { ok: false, chars, tool: c.cmd, error: String(e?.message || e).slice(0, 160) };
|
|
64
|
+
}
|
|
65
|
+
}
|
package/dist/cli/ink-app.js
CHANGED
|
@@ -8,6 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
* ↑/↓ 导航, Tab/Enter 选中, Esc 关闭, 弹出窗打开时 TextInput 让出焦点 (focus=false)
|
|
9
9
|
*/
|
|
10
10
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
11
|
+
import * as fs from 'fs';
|
|
11
12
|
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
12
13
|
import TextInput from 'ink-text-input';
|
|
13
14
|
import { dispWidth, LOADING_FRAMES as KAOMOJI } from './loading-tui.js';
|
|
@@ -29,23 +30,64 @@ function msgVisualLines(text, width) {
|
|
|
29
30
|
}
|
|
30
31
|
return n;
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* 分区账本 (2026-09-27 leo 口径: **面板高度必须确立 + 底部输入框打字不许抖**) ——
|
|
35
|
+
* 一屏就是终端的真实行数, 且**每一块的行数是常数**:
|
|
36
|
+
*
|
|
37
|
+
* 行 1 … history ← 历史区 (占满剩余高度; 消息窗口 + 弹层都在这里面)
|
|
38
|
+
* 行 history+1 ← 活动行 (思考/自动整理/暂停跟随提示; **固定预留 1 行**)
|
|
39
|
+
* 行…… ← 分隔线 / 状态栏 / 分隔线 / 输入栏 / 分隔线 (固定 5 行)
|
|
40
|
+
*
|
|
41
|
+
* 三条硬边界:
|
|
42
|
+
* · 几何恒定: 打字 / 提示出现 / 补全弹窗出现或消失, **输入行所在行号与其余区域位置一个字不动**
|
|
43
|
+
* (弹窗**覆盖**在历史区底部 —— 从 history 里扣, 不与固定栏抢位置);
|
|
44
|
+
* · **渲染总行数 == 终端高** —— 消息窗口切片 + 根容器 `overflow="hidden"` 兜底。
|
|
45
|
+
* (注: 修前帧高忽高忽低, 一旦某帧 > 终端高, Ink 的 `shouldClearTerminalForFrame` 会在**之后每一帧**
|
|
46
|
+
* 发 `ESC[2J ESC[3J ESC[H` —— 连回滚缓冲一起清, 于是"选不中 / 滚不回"同时出现。帧高恒定 = 那些清屏不再发。)
|
|
47
|
+
* · 暂停跟随时冻结整帧 (状态栏时钟也不 tick) ⇒ 帧字节不变 ⇒ 终端里的框选/复制不被打断。
|
|
48
|
+
*/
|
|
49
|
+
const CHROME_LINES = 5; // 3 条全宽分隔线 + 状态栏 + 输入栏
|
|
50
|
+
const ACTIVITY_LINES = 1; // 活动行 (固定预留, 免得状态一变就推挤历史区)
|
|
51
|
+
const RESERVED_LINES = CHROME_LINES + ACTIVITY_LINES;
|
|
52
|
+
const MIN_HISTORY_LINES = 1;
|
|
53
|
+
/** 弹层最多占几行 (给历史区留出至少 1 行消息, 免得弹窗把历史区吃光) */
|
|
54
|
+
const POPUP_MAX_ROWS = 8;
|
|
55
|
+
/** 一屏布局账本 (纯函数 —— 验收门直接喂尺寸复核) */
|
|
56
|
+
export function layoutBudget(opts) {
|
|
57
|
+
const rows = Math.max(RESERVED_LINES + MIN_HISTORY_LINES, Math.floor(opts.rows || 24));
|
|
58
|
+
const cols = Math.max(20, Math.floor(opts.cols || 80));
|
|
59
|
+
const history = Math.max(MIN_HISTORY_LINES, rows - RESERVED_LINES);
|
|
60
|
+
const popupRows = Math.max(0, Math.min(Math.floor(opts.popupRows || 0), POPUP_MAX_ROWS, Math.max(0, history - MIN_HISTORY_LINES)));
|
|
61
|
+
return {
|
|
62
|
+
rows, cols,
|
|
63
|
+
chrome: CHROME_LINES, activity: ACTIVITY_LINES,
|
|
64
|
+
history, msgH: history - popupRows, popupRows,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
32
67
|
// 2026-09-08: React.memo — msgs 引用不变时跳过重渲染 (配合虚拟化 slice, status tick 不再整表重绘)
|
|
33
68
|
const Messages = React.memo(({ msgs }) => (_jsx(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "flex-start", children: msgs.map((m, i) => {
|
|
34
69
|
const clean = m.replace(/\x1b\[[0-9;]*m/g, '');
|
|
35
70
|
return clean.trim() ? _jsx(Text, { children: clean ? m : '' }, i) : null;
|
|
36
71
|
}) })));
|
|
37
|
-
const MentionPopup = ({ title, items, sel, width, loading }) => {
|
|
38
|
-
const MAX_ROWS = 8;
|
|
39
|
-
// 2026-08-08: 滑动窗口 — 选中项始终可见 (原实现 fix 屏幕顶部, sel 超窗口时无高亮行)
|
|
40
|
-
const offset = Math.max(0, Math.min(sel - Math.floor(MAX_ROWS / 2), Math.max(0, items.length - MAX_ROWS)));
|
|
41
|
-
const shown = items.slice(offset, offset + MAX_ROWS);
|
|
72
|
+
const MentionPopup = ({ title, items, sel, width, loading, maxRows }) => {
|
|
42
73
|
const innerW = Math.max(width - 2, 10);
|
|
43
|
-
|
|
74
|
+
// 行账本: 2 行边框 + 正文若干 + (还有更多?1:0) —— 正文裁剪到刚好填满 maxRows
|
|
75
|
+
const totalRows = Math.max(1, maxRows);
|
|
76
|
+
const emptyNote = !loading && items.length === 0;
|
|
77
|
+
let bodyRows = Math.max(0, totalRows - 2 - (emptyNote ? 1 : 0));
|
|
78
|
+
let hasMore = items.length > bodyRows;
|
|
79
|
+
if (hasMore && bodyRows > 0)
|
|
80
|
+
bodyRows -= 1; // 给"还有 N 项"让一行
|
|
81
|
+
hasMore = items.length > bodyRows;
|
|
82
|
+
// 2026-08-08: 滑动窗口 — 选中项始终可见 (原实现 fix 屏幕顶部, sel 超窗口时无高亮行)
|
|
83
|
+
const offset = Math.max(0, Math.min(sel - Math.floor(bodyRows / 2), Math.max(0, items.length - bodyRows)));
|
|
84
|
+
const shown = items.slice(offset, offset + bodyRows);
|
|
85
|
+
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..." })) : emptyNote ? (_jsx(Text, { color: "dim", children: "\u2502 \u65E0\u5339\u914D" })) : (shown.map((it, i) => {
|
|
44
86
|
const active = i === sel;
|
|
45
87
|
const label = it.kind === 'file' ? it.label : `${it.kind === 'skill' ? '⚡' : it.kind === 'plugin' ? '🔌' : ''}${it.label}`;
|
|
46
88
|
const hint = it.hint ? `${it.hint}` : it.kind === 'file' ? '文件' : '';
|
|
47
89
|
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}`));
|
|
48
|
-
})),
|
|
90
|
+
})), hasMore && (_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)}╯` })] }));
|
|
49
91
|
};
|
|
50
92
|
const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH }) => {
|
|
51
93
|
const [input, setInput] = useState('');
|
|
@@ -90,15 +132,27 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
90
132
|
const [picker, setPicker] = useState(null);
|
|
91
133
|
const pickerCb = useRef(null);
|
|
92
134
|
const pickerSelRef = useRef(0);
|
|
135
|
+
/**
|
|
136
|
+
* 2026-09-26: 取消回调。此前 Esc 只把 `pickerCb` 置空并关窗 —— 对"等一个选择结果"的调用方
|
|
137
|
+
* (分步选择器要按步往下走) 来说就是**永远等不到**。现在 Esc/显式关闭都会回调它。
|
|
138
|
+
*/
|
|
139
|
+
const pickerCancel = useRef(null);
|
|
93
140
|
// 全局钩子: index.ts 命令打开/关闭选择器
|
|
94
141
|
useEffect(() => {
|
|
95
|
-
globalThis.__inkOpenPicker = (itemsArg, title, onPick) => {
|
|
142
|
+
globalThis.__inkOpenPicker = (itemsArg, title, onPick, onCancel) => {
|
|
96
143
|
pickerSelRef.current = 0;
|
|
97
144
|
pickerCb.current = onPick;
|
|
145
|
+
pickerCancel.current = onCancel ?? null;
|
|
98
146
|
setPicker({ title: title || '选择', items: itemsArg, sel: 0 });
|
|
99
147
|
setInput('');
|
|
100
148
|
};
|
|
101
|
-
globalThis.__inkClosePicker = () => {
|
|
149
|
+
globalThis.__inkClosePicker = () => {
|
|
150
|
+
const c = pickerCancel.current;
|
|
151
|
+
pickerCb.current = null;
|
|
152
|
+
pickerCancel.current = null;
|
|
153
|
+
setPicker(null);
|
|
154
|
+
c?.();
|
|
155
|
+
};
|
|
102
156
|
return () => { delete globalThis.__inkOpenPicker; delete globalThis.__inkClosePicker; };
|
|
103
157
|
}, []);
|
|
104
158
|
// ── 输入历史 (↑/↓ 切换) ───────────────────────────────────────────────────
|
|
@@ -392,13 +446,18 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
392
446
|
const it = itemsArg[Math.min(pickerSelRef.current, itemsArg.length - 1)];
|
|
393
447
|
const cb = pickerCb.current;
|
|
394
448
|
pickerCb.current = null;
|
|
449
|
+
pickerCancel.current = null;
|
|
395
450
|
setPicker(null);
|
|
396
451
|
cb?.(it);
|
|
397
452
|
return;
|
|
398
453
|
}
|
|
399
454
|
if (key.escape) {
|
|
455
|
+
// 取消也要**回调**调用方 (否则等结果的调用方永远挂着)
|
|
456
|
+
const c = pickerCancel.current;
|
|
400
457
|
pickerCb.current = null;
|
|
458
|
+
pickerCancel.current = null;
|
|
401
459
|
setPicker(null);
|
|
460
|
+
c?.();
|
|
402
461
|
return;
|
|
403
462
|
}
|
|
404
463
|
return; // 其余键忽略
|
|
@@ -528,7 +587,9 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
528
587
|
return;
|
|
529
588
|
}
|
|
530
589
|
// 双击 Esc 退出当前进程: 第一击提示, 500ms 内第二击退出
|
|
531
|
-
|
|
590
|
+
// ⚠ 判据含**字节级兜底** (`_input === '\u001b'`): Ink 对"孤独的 Esc"要先攒 20ms 再吐,
|
|
591
|
+
// 重挂之后实测 `key.escape` 不再为真 —— 与上面 Enter 那条同一个道理 (不依赖 Ink 的键解析)。
|
|
592
|
+
if (key.escape || _input === '\u001b') {
|
|
532
593
|
const now = Date.now();
|
|
533
594
|
if (now - lastEscRef.current < DOUBLE_ESC_MS) {
|
|
534
595
|
requestExit();
|
|
@@ -558,7 +619,8 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
558
619
|
// 自动更新状态栏 (每秒)
|
|
559
620
|
// 2026-08-07 修复: 依赖必须为空 [] — [getStatusUpdate] 在渲染间引用变化 (Ink 内部元素重建),
|
|
560
621
|
// effect 每次渲染 cleanup+setup → setInterval 刚建立就被清除 → 永不 tick → 状态栏恒初始值.
|
|
561
|
-
//
|
|
622
|
+
// 2026-09-27: **暂停跟随时不 tick** —— 用户上滚后整帧冻结 (Ink 的 log-update 在输出相同时
|
|
623
|
+
// 一个字节都不写), 于是终端里的框选/复制不会被打断, 也保证"底部行字节不再变化"。
|
|
562
624
|
useEffect(() => {
|
|
563
625
|
// 挂载时立即同步刷新一次状态栏 (不等 1s 后第一个 tick)
|
|
564
626
|
try {
|
|
@@ -568,6 +630,8 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
568
630
|
}
|
|
569
631
|
catch { /* 状态栏更新失败不致命 */ }
|
|
570
632
|
const timer = setInterval(() => {
|
|
633
|
+
if (!stickRef.current)
|
|
634
|
+
return; // 暂停跟随: 冻结整帧 (回到最底自动恢复)
|
|
571
635
|
try {
|
|
572
636
|
const s = getStatusUpdate();
|
|
573
637
|
if (s)
|
|
@@ -603,7 +667,16 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
603
667
|
// ── 虚拟化 transcript (消息级窗口 + 自动跟随底部) ───────────────────────────
|
|
604
668
|
// #1 分区预留: chrome 固定行 = 分隔线×3 + 状态栏 + 输入栏 = 5; transcript 严格不超区
|
|
605
669
|
// (content Box flexGrow=1 但内容超宽会把 status/composer 挤走 → 可见窗口行数钳制 ≤ availH)
|
|
606
|
-
|
|
670
|
+
// 2026-09-27: 高度**全部来自真终端尺寸** (`layoutBudget`) —— 帧高 == 终端高 (常数),
|
|
671
|
+
// 弹层从历史区里扣 (覆盖式, 不推挤固定栏) ⇒ 打字 / 提示出现都不动输入行位置。
|
|
672
|
+
const sticky = stickRef.current;
|
|
673
|
+
const popupOpenNow = !!(popupOpen || picker);
|
|
674
|
+
const budget = layoutBudget({
|
|
675
|
+
rows: termSize.h, cols: W,
|
|
676
|
+
popupRows: popupOpenNow ? POPUP_MAX_ROWS : 0,
|
|
677
|
+
});
|
|
678
|
+
const msgH = budget.msgH; // 消息窗口高度 (弹层占的那几行已经扣掉)
|
|
679
|
+
const availH = budget.history; // 滚动按"历史区总高"算 (含被弹层覆盖的几行)
|
|
607
680
|
const heights = useMemo(() => msgs.map(m => msgVisualLines(m, W)), [msgs, W]);
|
|
608
681
|
const cumulative = useMemo(() => {
|
|
609
682
|
const c = [0];
|
|
@@ -612,26 +685,47 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
612
685
|
return c;
|
|
613
686
|
}, [heights]);
|
|
614
687
|
const totalLines = cumulative[cumulative.length - 1] || 0;
|
|
615
|
-
const maxTop = Math.max(0, totalLines -
|
|
616
|
-
const sticky = stickRef.current;
|
|
688
|
+
const maxTop = Math.max(0, totalLines - msgH);
|
|
617
689
|
const top = sticky ? maxTop : Math.min(scrollTop, maxTop);
|
|
618
690
|
let start = 0;
|
|
619
691
|
while (start < msgs.length && cumulative[start + 1] <= top + 0.5)
|
|
620
692
|
start++;
|
|
621
693
|
let end = start;
|
|
622
|
-
// 严格钳制: 端界不越过
|
|
623
|
-
while (end + 1 < msgs.length && cumulative[end + 1] <= top +
|
|
694
|
+
// 严格钳制: 端界不越过 msgH, 保证 transcript 内容行数 ≤ 分区高度 (互不覆盖)
|
|
695
|
+
while (end + 1 < msgs.length && cumulative[end + 1] <= top + msgH)
|
|
624
696
|
end++;
|
|
625
697
|
const visible = useMemo(() => msgs.slice(start, end + 1), [msgs, start, end]);
|
|
626
698
|
const scrolledOut = maxTop > 0 && !sticky;
|
|
627
|
-
|
|
699
|
+
// 测试/诊断探针: 把**真实用到的**布局与滚动状态写一份 JSONL (验收门按它做精确断言;
|
|
700
|
+
// 不设 BOLLOON_TUI_PROBE 时零开销 —— 只在渲染后追加一行)。
|
|
701
|
+
// 注: 走静态 import —— dist 是 ESM, 这里 `require()` 不存在 (踩过一次: 探针静默不写 + 树停更)。
|
|
702
|
+
useEffect(() => {
|
|
703
|
+
const p = process.env.BOLLOON_TUI_PROBE;
|
|
704
|
+
if (!p)
|
|
705
|
+
return;
|
|
706
|
+
try {
|
|
707
|
+
fs.appendFileSync(p, JSON.stringify({
|
|
708
|
+
t: Date.now(), rows: budget.rows, cols: budget.cols, chrome: budget.chrome,
|
|
709
|
+
activity: budget.activity, history: budget.history, msgH, popupRows: budget.popupRows,
|
|
710
|
+
inputLine: budget.rows - 1, // 输入栏行号 (1-based; 最后一行是底部分隔线)
|
|
711
|
+
totalMsgs: msgs.length, totalLines, top, maxTop, stick: sticky,
|
|
712
|
+
firstVisible: start, lastVisible: end, visibleCount: visible.length,
|
|
713
|
+
input, pausedHint: scrolledOut ? '已暂停跟随 · PgDn/End 回到底部' : '',
|
|
714
|
+
}) + '\n');
|
|
715
|
+
}
|
|
716
|
+
catch { /* 探针写不进去绝不影响界面 */ }
|
|
717
|
+
});
|
|
718
|
+
return (_jsxs(Box, { flexDirection: "column", height: budget.rows, width: budget.cols, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", height: budget.history, children: [_jsxs(Box, { height: msgH, overflow: "hidden", flexDirection: hasRails ? 'row' : 'column', children: [_jsx(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", overflow: "hidden", children: _jsx(Messages, { msgs: visible }) }), 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)) }))] }), popupOpenNow && tabState && (_jsx(MentionPopup, { title: POPUP_TITLE_TAB, items: filtered, sel: safeSel, width: W, maxRows: budget.popupRows })), popupOpenNow && !tabState && mention && (_jsx(MentionPopup, { title: popupTitle, items: filtered, sel: safeSel, width: W, loading: loadingFiles, maxRows: budget.popupRows })), popupOpenNow && !tabState && !mention && picker && (_jsx(MentionPopup, { title: picker.title, items: picker.items, sel: Math.min(picker.sel, picker.items.length - 1), width: W, maxRows: budget.popupRows }))] }), _jsx(Box, { height: budget.activity, width: budget.cols, overflow: "hidden", children: scrolledOut ? (_jsxs(Text, { color: THEME.warn, children: ["\u5DF2\u6682\u505C\u8DDF\u968F \u00B7 PgDn/End \u56DE\u5230\u5E95\u90E8 (\u2191", top, " \u884C \u00B7 Ctrl+U/D \u7FFB\u9875 \u00B7 Home \u5230\u9876)"] })) : transient ? (_jsx(Text, { children: transient })) : thinking ? (_jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] })) : (_jsx(Text, { children: " " })) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: THEME.accent, children: '─'.repeat(W) }) }), _jsx(Box, { height: 1, width: budget.cols, overflow: "hidden", children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: THEME.accent, children: '─'.repeat(W) }) }), _jsxs(Box, { width: budget.cols, height: 1, overflow: "hidden", children: [_jsx(Text, { bold: true, color: THEME.accent, children: "\u276F " }), _jsx(Box, { width: Math.max(10, budget.cols - 2), height: 1, overflow: "hidden", flexShrink: 0, children: _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, focus: !popupOpenNow, placeholder: COMPOSER_PLACEHOLDER }, tiKey) })] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: THEME.accent, children: '─'.repeat(W) }) })] }));
|
|
628
719
|
};
|
|
629
720
|
export { InkApp };
|
|
630
721
|
// ─── 启动 ────────────────────────────────────────────────────────────────────
|
|
631
722
|
let _inkInstance = null;
|
|
723
|
+
/** 上次 render 的参数 (suspend → resume 要原样重挂, 所以必须留着) */
|
|
724
|
+
let _lastInkArgs = null;
|
|
632
725
|
export function startInk(onPrompt, initialStatus, getStatusUpdate) {
|
|
633
726
|
const tw = process.stdout.columns || 80;
|
|
634
727
|
const th = process.stdout.rows || 24;
|
|
728
|
+
_lastInkArgs = { onPrompt, initialStatus, getStatusUpdate };
|
|
635
729
|
_inkInstance = render(_jsx(InkApp, { onPrompt: onPrompt, initialStatus: initialStatus, getStatusUpdate: getStatusUpdate, terminalW: tw, terminalH: th }), {
|
|
636
730
|
stdout: process.stdout,
|
|
637
731
|
stdin: process.stdin,
|
|
@@ -639,12 +733,46 @@ export function startInk(onPrompt, initialStatus, getStatusUpdate) {
|
|
|
639
733
|
patchConsole: false, // 重要: 阻止 Ink 劫持 console.log
|
|
640
734
|
});
|
|
641
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* 暂时让出终端 (2026-09-27): 会话内 `/model` 要用**与 `bolloon model` 同一个**全屏选择器组件
|
|
738
|
+
* (`tui-select.ts` —— 固定高度视窗 + 跟随 + 折叠 + 搜索 + 光标高亮), 而它自己是 raw-mode
|
|
739
|
+
* 逐键渲染: 两个渲染器同时抢同一个终端必然互相撕。
|
|
740
|
+
*
|
|
741
|
+
* 语义: 先把 Ink 卸掉并清掉它那一帧 (终端干净), 交给选择器画; 用完 `resumeInk()` 原样挂回来。
|
|
742
|
+
* 为什么可以这么做: 会话内容活在**外部 store** (`stores.ts` 的 transcriptStore/uiStore), 不在 React 里,
|
|
743
|
+
* 所以重挂之后历史一字不少 (只丢"输入框草稿"这种真正的临时态)。
|
|
744
|
+
*/
|
|
745
|
+
export function suspendInk() {
|
|
746
|
+
if (!_inkInstance)
|
|
747
|
+
return;
|
|
748
|
+
try {
|
|
749
|
+
_inkInstance.unmount();
|
|
750
|
+
_inkInstance.clear();
|
|
751
|
+
}
|
|
752
|
+
catch { /* 卸不掉也让调用方继续 (选择器会自己清干净) */ }
|
|
753
|
+
_inkInstance = null;
|
|
754
|
+
}
|
|
755
|
+
/** 把 Ink 按上次的参数挂回来 (suspendInk 之后用); 没挂过则忽略 */
|
|
756
|
+
export function resumeInk() {
|
|
757
|
+
if (_inkInstance || !_lastInkArgs)
|
|
758
|
+
return;
|
|
759
|
+
const a = _lastInkArgs;
|
|
760
|
+
startInk(a.onPrompt, a.initialStatus, a.getStatusUpdate);
|
|
761
|
+
// ⚠ 必须把 stdin **重新拉起来**: 选择器的 `RawSession.close()` 会 `stdin.pause()`,
|
|
762
|
+
// 而 Ink 挂载时只 `setRawMode(true)`、**自己不会 resume** ⇒ 少这一下, 面板照常重绘,
|
|
763
|
+
// 但按键再也不进来 —— 实测: 打字不回显、Esc 双击退出也没反应 (2026-09-27 验收门抓到)。
|
|
764
|
+
try {
|
|
765
|
+
process.stdin.resume();
|
|
766
|
+
}
|
|
767
|
+
catch { /* 不支持的宿主, 不影响渲染 */ }
|
|
768
|
+
}
|
|
642
769
|
export function stopInk() {
|
|
643
770
|
if (_inkInstance) {
|
|
644
771
|
_inkInstance.unmount();
|
|
645
772
|
_inkInstance.clear();
|
|
646
773
|
_inkInstance = null;
|
|
647
774
|
}
|
|
775
|
+
_lastInkArgs = null;
|
|
648
776
|
}
|
|
649
777
|
export function inkAppendLine(line) {
|
|
650
778
|
appendMsg(line);
|