@bolloon/bolloon-agent 0.4.17 → 0.4.19

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.
@@ -13,6 +13,8 @@ import * as path from 'path';
13
13
  import { getCIDDatabase } from '../orbitdb/cid-database.js';
14
14
  const home = () => process.env.HOME || os.homedir() || '/tmp';
15
15
  export const REGISTRY_ORBIT_KEY = 'services';
16
+ /** 网络启动包 meta 键 (networkId/name/version/capacityOfMembers/sharedContextCid) */
17
+ export const REGISTRY_ORBIT_META_KEY = 'meta';
16
18
  /** OrbitDB store 名 (按 did 短名确定性) */
17
19
  export function registryStoreName(did) {
18
20
  const short = String(did || 'local').replace(/[^a-zA-Z0-9-]/g, '_').slice(0, 40) || 'local';
@@ -118,6 +120,28 @@ export class OrbitDBAgentRegistry {
118
120
  return orbit;
119
121
  return this.loadLocalFromDisk();
120
122
  }
123
+ async writeMeta(meta) {
124
+ if (!this.ready || !this._store)
125
+ return { ok: false, error: 'registry store 未就绪 (离线模式)' };
126
+ try {
127
+ await this._store.put(REGISTRY_ORBIT_META_KEY, meta);
128
+ return { ok: true };
129
+ }
130
+ catch (e) {
131
+ return { ok: false, error: String(e?.message || e).slice(0, 120) };
132
+ }
133
+ }
134
+ async readMeta() {
135
+ if (!this.ready || !this._store)
136
+ return null;
137
+ try {
138
+ const v = await this._store.get(REGISTRY_ORBIT_META_KEY);
139
+ return v && typeof v === 'object' ? v : null;
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
121
145
  async discover(query) {
122
146
  const all = await this.list();
123
147
  const q = String(query || '').trim().toLowerCase();
@@ -182,30 +182,34 @@ export async function joinNetwork(link, deps) {
182
182
  // 合并到本地 registry (按 agentId + service.name 去重)
183
183
  const registry = deps?.registry ?? getAgentRegistry();
184
184
  const local = await registry.list();
185
- let joined = 0;
186
- for (const svc of remote) {
187
- if (!svc?.agentId || !svc?.service?.name)
188
- continue;
189
- const exists = local.some((l) => l.agentId === svc.agentId && l.service?.name === svc.service?.name);
190
- if (!exists) {
185
+ const remoteServices = Array.isArray(remote) ? remote : [];
186
+ const { merged, joined } = mergeRemoteServices(remoteServices, local);
187
+ for (const svc of merged) {
188
+ if (!local.some((l) => l.agentId === svc.agentId && l.service?.name === svc.service?.name)) {
191
189
  await registry.register(svc).catch(() => { });
192
- joined++;
193
190
  }
194
191
  }
195
- // 记录成员身份 (持久化 重启后自动恢复)
192
+ // 网络启动包 (meta): 拉网络声明 (networkId/名称/版本/容量/共享context CID), 尽力而为
193
+ const meta = await fetchNetworkMeta(parsed).catch(() => null);
194
+ const bootstrap = buildNetworkBootstrap(meta);
195
+ // 记录成员身份 (持久化 → 重启后自动恢复) + 网络启动包信息
196
196
  await saveNetworks([
197
197
  ...existing,
198
198
  {
199
199
  link: norm,
200
200
  linkKey: identityKey,
201
201
  kind: parsed.kind,
202
- name: parsed.networkName,
202
+ name: bootstrap.name || parsed.networkName,
203
203
  joinedAt: new Date().toISOString(),
204
- serviceCount: remote.length,
204
+ serviceCount: remoteServices.length,
205
205
  lastSyncAt: new Date().toISOString(),
206
+ networkId: bootstrap.networkId,
207
+ version: bootstrap.version,
208
+ capacityOfMembers: bootstrap.capacityOfMembers,
209
+ sharedContextCid: bootstrap.sharedContextCid,
206
210
  },
207
211
  ]);
208
- return { ok: true, joined, total: remote.length, linkKind: parsed.kind, networkName: parsed.networkName };
212
+ return { ok: true, joined, total: remoteServices.length, linkKind: parsed.kind, networkName: bootstrap.name || parsed.networkName, networkId: bootstrap.networkId, sharedContextCid: bootstrap.sharedContextCid };
209
213
  }
210
214
  /** 启动恢复: 重拉所有已加入网络 (失败静默, 保留记录). 返回恢复统计. */
211
215
  export async function restoreJoinedNetworks() {
@@ -228,6 +232,156 @@ export async function restoreJoinedNetworks() {
228
232
  }
229
233
  return { restored, failed, total: nets.length };
230
234
  }
235
+ /** 把任意 meta 对象规整成 NetworkBootstrap (纯函数, 容错) */
236
+ export function buildNetworkBootstrap(meta) {
237
+ const m = meta && typeof meta === 'object' ? meta : {};
238
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
239
+ return {
240
+ networkId: str(m.networkId),
241
+ name: str(m.name),
242
+ version: str(m.version),
243
+ capacityOfMembers: typeof m.capacityOfMembers === 'number' ? m.capacityOfMembers : undefined,
244
+ sharedContextCid: str(m.sharedContextCid),
245
+ };
246
+ }
247
+ /** 拉取网络 meta (尽力而为, 失败返回 null): orbitdb('meta' 键) / ipns(network.json) / http(doc.meta) */
248
+ async function fetchNetworkMeta(parsed) {
249
+ try {
250
+ if (parsed.kind === 'http') {
251
+ const r = await fetch(parsed.url, { signal: AbortSignal.timeout(15000) });
252
+ if (!r.ok)
253
+ return null;
254
+ const d = await r.json();
255
+ return d?.meta ?? null;
256
+ }
257
+ if (parsed.kind === 'ipns') {
258
+ const r = await fetch(`http://127.0.0.1:8080/ipns/${parsed.name}/network.json`, { signal: AbortSignal.timeout(15000) });
259
+ if (!r.ok)
260
+ return null;
261
+ const d = await r.json();
262
+ return d?.meta ?? d;
263
+ }
264
+ if (parsed.kind === 'orbitdb') {
265
+ const { getCIDDatabase } = await import('../orbitdb/cid-database.js');
266
+ const db = getCIDDatabase();
267
+ const store = await db.openStoreByAddress(parsed.address, 'keyvalue');
268
+ if (!store)
269
+ return null;
270
+ const v = await store.get('meta').catch(() => null);
271
+ return v ?? null;
272
+ }
273
+ return null;
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ }
279
+ /** 纯函数: 远端服务并入本地 (按 agentId + service.name 去重), 返回合并结果 + 新增数 */
280
+ export function mergeRemoteServices(remote, local) {
281
+ const out = local.slice();
282
+ let joined = 0;
283
+ for (const svc of remote) {
284
+ if (!svc?.agentId || !svc?.service?.name)
285
+ continue;
286
+ const exists = out.some((l) => l.agentId === svc.agentId && l.service?.name === svc.service?.name);
287
+ if (!exists) {
288
+ out.push(svc);
289
+ joined++;
290
+ }
291
+ }
292
+ return { merged: out, joined };
293
+ }
294
+ /** ② 拉取网络画像 (只读, 不落盘): 供 on-join 告知 agent "网络里有什么" */
295
+ export async function pullNetworkProfile(link) {
296
+ const parsed = parseNetworkLink(link);
297
+ if (!parsed)
298
+ return null;
299
+ let services = null;
300
+ if (parsed.kind === 'http')
301
+ services = await fetchRemoteRegistry(parsed.url);
302
+ else if (parsed.kind === 'ipns')
303
+ services = await fetchIpnsRegistry(parsed.name);
304
+ else if (parsed.kind === 'orbitdb')
305
+ services = await fetchOrbitdbRegistry(parsed.address);
306
+ const meta = await fetchNetworkMeta(parsed).catch(() => null);
307
+ if (!services || services.length === 0)
308
+ return null;
309
+ return { bootstrap: buildNetworkBootstrap(meta), members: services };
310
+ }
311
+ /**
312
+ * ③ 成员自描述广播: 把本机服务声明(list .agentId 匹配 self)写回共享网络 store ('services' 合并 self).
313
+ * ① ipns/http 无回写 → 只本地登记 (返回 note); ② orbitdb replica 只读时写穿失败 → 非致命 (返回 note).
314
+ * opts.members: 本机要广播的成员声明 (通常 = 自己注册的 AgentService 列表).
315
+ */
316
+ export async function networkShareSelf(link, members, opts) {
317
+ if (!members || members.length === 0)
318
+ return { ok: true, note: '无本机成员声明可广播' };
319
+ const parsed = parseNetworkLink(link);
320
+ if (!parsed)
321
+ return { ok: false, error: '链接无法解析' };
322
+ if (parsed.kind !== 'orbitdb') {
323
+ return { ok: true, note: `${parsed.kind} 无回写能力, 已本地登记 (发起方自托管 registry 应含本机)` };
324
+ }
325
+ try {
326
+ const { getCIDDatabase } = await import('../orbitdb/cid-database.js');
327
+ const db = getCIDDatabase();
328
+ const store = await db.openStoreByAddress(parsed.address, 'keyvalue');
329
+ if (!store)
330
+ return { ok: false, error: '网络 store 不可达' };
331
+ const cur = (await store.get('services').catch(() => null));
332
+ const arr = Array.isArray(cur) ? cur.slice() : [];
333
+ for (const m of members) {
334
+ const i = arr.findIndex((s) => s.agentId === m.agentId && s.service?.name === m.service?.name);
335
+ const entry = { ...m, updatedAt: new Date().toISOString(), registeredAt: m.registeredAt || new Date().toISOString() };
336
+ if (i >= 0)
337
+ arr[i] = entry;
338
+ else
339
+ arr.push(entry);
340
+ }
341
+ await store.put('services', arr);
342
+ return { ok: true, note: `已广播 ${members.length} 条本机声明到网络` };
343
+ }
344
+ catch (e) {
345
+ return { ok: false, error: `写穿失败(疑似只读 replica): ${String(e?.message || e).slice(0, 120)}`, note: '已在本地登记, 可在你的分享链接中带上本机' };
346
+ }
347
+ }
348
+ /**
349
+ * ① 拉取共享 context (由启动包 sharedContextCid 指向): 从本地 IPFS 网关读文本返回,
350
+ * 调用方负责注入本地 context (了解"所有信息"的最后一段).
351
+ */
352
+ export async function pullNetworkSharedContext(cid) {
353
+ try {
354
+ const r = await fetch(`http://127.0.0.1:8080/ipfs/${cid}`, { signal: AbortSignal.timeout(15000) });
355
+ if (!r.ok)
356
+ return null;
357
+ return await r.text();
358
+ }
359
+ catch {
360
+ return null;
361
+ }
362
+ }
363
+ /**
364
+ * ① 发布共享 context 到 OrbitDB (内容寻址), 返回 CID — 供 shareNetworkLink 的 sharedContextCid,
365
+ * 手机/PC 入网后拉取即"近期上下文同步". opts.share=true 时把块放入 helia blockstore 供网络拉取.
366
+ */
367
+ export async function publishNetworkSharedContext(text, opts) {
368
+ try {
369
+ const { getCIDDatabase } = await import('../orbitdb/cid-database.js');
370
+ const db = getCIDDatabase();
371
+ const rec = await db.save({ agentId: opts?.agentId || 'bolloon-network', type: 'context', content: text });
372
+ let cid = rec.id;
373
+ if (opts?.share !== false) {
374
+ try {
375
+ cid = await db.share(cid);
376
+ }
377
+ catch { /* 分享失败仍可用 CID 读 */ }
378
+ }
379
+ return { ok: true, cid };
380
+ }
381
+ catch (e) {
382
+ return { ok: false, error: String(e?.message || e).slice(0, 140) };
383
+ }
384
+ }
231
385
  // ============ 分享链接 ============
232
386
  /**
233
387
  * 生成本机可分享的网络链接: orbitdb://<registry storeAddress>?name=<网络名>.
@@ -244,8 +398,24 @@ export async function shareNetworkLink(opts) {
244
398
  if (!registry.ready || !registry.storeAddress) {
245
399
  return { ok: false, error: 'OrbitDB registry 未就绪 (离线模式). 备选: 把 registry 列表发布成 https://.../registry 端点分享' };
246
400
  }
247
- const name = encodeURIComponent(String(opts?.name || registry.storeName || 'bolloon-network'));
248
- return { ok: true, link: `orbitdb://${registry.storeAddress}?name=${name}` };
401
+ // 全量启动包: networkId/名称/版本/容量/共享context CID 写进共享 store (尽力而为, 失败仍分享)
402
+ const networkId = opts?.meta?.networkId || registry.storeName;
403
+ const name = String(opts?.name || opts?.meta?.name || registry.storeName || 'bolloon-network');
404
+ const metaObj = {
405
+ networkId,
406
+ name,
407
+ version: opts?.meta?.version,
408
+ capacityOfMembers: opts?.meta?.capacityOfMembers,
409
+ sharedContextCid: opts?.meta?.sharedContextCid,
410
+ };
411
+ let note;
412
+ if (registry.writeMeta) {
413
+ const w = await registry.writeMeta(metaObj).catch(() => ({ ok: false, error: 'writeMeta 异常' }));
414
+ if (!w.ok)
415
+ note = `meta 写共享库失败 (${w.error || '未知'}) — 读方仍可拉到服务, 但拿不到启动包`;
416
+ }
417
+ const encodedName = encodeURIComponent(name);
418
+ return { ok: true, link: `orbitdb://${registry.storeAddress}?name=${encodedName}`, note };
249
419
  }
250
420
  catch (e) {
251
421
  return { ok: false, error: `生成分享链接失败: ${String(e?.message || e).slice(0, 160)}` };
@@ -268,7 +438,15 @@ export async function maybeAutoJoinGateway(text, deps) {
268
438
  return null; // 已在网络, 静默
269
439
  if (r.ok) {
270
440
  const netName = r.networkName ? `「${r.networkName}」` : ''; // URLSearchParams 已 decode
271
- return `🆕 已自动加入 Agent 网络${netName} (${r.linkKind}): 拉取 ${r.total} 个服务, 新增 ${r.joined} 个。用 gateway_status 查看, gateway_call 调用网络里的服务。`;
441
+ // on-join 广播: 写入自己的服务声明 (orbitdb 可回写时), 非致命
442
+ let selfNote = '';
443
+ if (deps?.self && deps.self.length > 0) {
444
+ const s = await networkShareSelf(link, deps.self, deps).catch(() => null);
445
+ selfNote = s?.ok ? ' · 已广播本机声明' : (s?.note ? ` · ${s.note}` : ' · 本机声明广播失败');
446
+ }
447
+ const bootPart = r.networkId ? ` (net=${String(r.networkId).slice(0, 16)})` : '';
448
+ const ctxPart = r.sharedContextCid ? ` · 共享ctx:${String(r.sharedContextCid).slice(0, 12)}` : '';
449
+ return `🆕 已自动加入 Agent 网络${netName}${bootPart} (${r.linkKind}): 拉取 ${r.total} 个服务, 新增 ${r.joined} 个${ctxPart}${selfNote}。用 gateway_status 查看, gateway_call 调用网络里的服务。`;
272
450
  }
273
451
  return `⚠️ 检测到 Agent 网络链接 (${r.linkKind || 'unknown'}) 但加入失败: ${r.error}`;
274
452
  }
@@ -0,0 +1,33 @@
1
+ // ─── 网络链接纯函数 (无 Node 依赖, 桌面/手机浏览器共用) ────────────────────
2
+ // parseNetworkLink / detectGatewayLink 从 gateway-network.ts 抽出,
3
+ // 供 browser 端 (mobile-gateway) 复用, 不拖 OrbitDB/fs 进 WebView.
4
+ /** 解析链接字符串 → ParsedNetworkLink (剥离 ?name= query) */
5
+ export function parseNetworkLink(link) {
6
+ const l = String(link || '').trim();
7
+ if (!l)
8
+ return null;
9
+ const [base, query] = l.split('?');
10
+ let networkName;
11
+ try {
12
+ networkName = query ? (new URLSearchParams(query).get('name') || undefined) : undefined;
13
+ }
14
+ catch { /* 忽略坏 query */ }
15
+ if (base.startsWith('ipns://'))
16
+ return { kind: 'ipns', value: base.slice('ipns://'.length), url: base, networkName };
17
+ if (base.startsWith('orbitdb://')) {
18
+ let address = base.slice('orbitdb://'.length);
19
+ if (!address.startsWith('/'))
20
+ address = `/${address}`;
21
+ return { kind: 'orbitdb', value: address, url: base, networkName };
22
+ }
23
+ if (base.startsWith('http://') || base.startsWith('https://'))
24
+ return { kind: 'http', value: base, url: base, networkName };
25
+ return null;
26
+ }
27
+ /** 从消息文本检测 gateway 链接 (自动加入触发器用) */
28
+ export function detectGatewayLink(text) {
29
+ const t = String(text || '');
30
+ const re = /(orbitdb:\/\/\/?orbitdb\/[^\s)'"<>,。;]+|ipns:\/\/[^\s)'"<>,。;]+|https?:\/\/[^\s)'"<>,。;]*\/registry(?:\?[^\s)'"<>,。;]*)?)/;
31
+ const m = re.exec(t);
32
+ return m ? m[1].trim() : null;
33
+ }
@@ -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 = ['(`・ω・´)', '(´・_・`)', '(。•́︿•̀。)', 'ᕙ(▀̿̿Ĺ̯̿̿▀̿ ̿)ᕗ', '(◕‿◕)', 'ヽ(´▽`)/'];
@@ -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 { brandArtLines, boxTop, boxRow, boxBottom, dispWidth } from './loading-tui.js';
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
- const Messages = ({ msgs }) => (_jsx(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "flex-start", children: msgs.map((m, i) => {
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: "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) => {
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: "cyan", children: `╰${'─'.repeat(innerW)}╯` })] }));
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 [msgs, setMsgs] = useState([]);
54
- const [status, setStatus] = useState(initialStatus);
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
- const [thinking, setThinking] = useState(false);
57
- // 2026-08-10: 临时状态行 (自动整理心跳/run-end 经验整理用) — 显示在颜文字行位置,
58
- // 结束后设 null 即清空 (显示为空). 不进入消息历史, 不会残留显示效果.
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 = '\x1b[38;2;245;158;11m'; // #f59e0b
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 ? 'Tab 补齐'
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) => setThinking(v);
286
- globalThis.__inkAppend = (line) => {
287
- setMsgs(prev => [...prev, line]);
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
- setTransient(v === undefined ? null : v);
295
- };
296
- // 2026-08-12 (Task4): 原地替换最后一条消息 (命令加载态 完成态用).
297
- // 不命中则追加 (兼容旧逻辑).
298
- globalThis.__inkReplaceLast = (line) => {
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 < 500) {
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
- setStatus(s0);
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
- setStatus(s);
574
+ setUiStatus(s);
533
575
  }
534
576
  catch { /* 状态栏更新失败不致命 */ }
535
- }, 1000);
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);
600
+ }, THINK_FRAME_MS);
560
601
  return () => clearInterval(timer);
561
602
  }, [thinking]);
562
- 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..."] }) })), transient && (_jsx(Box, { children: _jsx(Text, { children: transient }) }))] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "#c4d640", children: '─'.repeat(terminalW) }) }), _jsx(Box, { children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "#c4d640", children: '─'.repeat(terminalW) }) }), popupOpen && (tabState || mention) && (_jsx(MentionPopup, { title: popupTitle, items: filtered, sel: safeSel, width: terminalW, loading: loadingFiles })), picker && (_jsx(MentionPopup, { title: picker.title, items: picker.items, sel: Math.min(picker.sel, picker.items.length - 1), width: terminalW })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "#c4d640", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, focus: !popupOpen && !picker, 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: "#c4d640", children: '─'.repeat(terminalW) }) })] }));
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
- const fn = globalThis.__inkAppend;
586
- if (fn)
587
- fn(line);
650
+ appendMsg(line);
588
651
  }
589
652
  /** 2026-08-12 (Task4): 原地替换最后一条消息 (命令加载态 → 完成态). 无消息时追加. */
590
653
  export function inkReplaceLastLine(line) {
591
- const fn = globalThis.__inkReplaceLast;
592
- if (fn)
593
- fn(line);
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
- const fn = globalThis.__inkSetThinking;
602
- if (fn)
603
- fn(v);
666
+ setUiThinking(v);
604
667
  }
605
668
  /** 2026-08-10: 设置/清除临时状态行 (自动整理/经验整理). 传 null 清空 → 显示为空 */
606
669
  export function inkSetTransient(v) {
607
- const fn = globalThis.__inkSetTransient;
608
- if (fn)
609
- fn(v);
670
+ setUiTransient(v);
610
671
  }