@fanchao8609/agent_brain_sync 1.26.1 → 1.28.0

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 CHANGED
@@ -122,11 +122,11 @@ abs log "完成 X" # 记一行流水;abs log 无参 =
122
122
  abs query <词> # 检索图谱(多词 OR);已被推翻的经验默认隐藏(--all 可看)
123
123
  abs capture [--layer 1-6] [--kind …] "一句话" # 按七层判层后落到位(L7 用 abs note;--kind: gotcha|decision|tradeoff|howto)
124
124
  abs db rebuild # 扫所有登记项目 → 建图数据库(~/.abs/graph.db)
125
- abs db search <词> [--full] [--no-rules] # 走库检索:亮度排序+跨项目;--full 附正文,--no-rules 省铁律
125
+ abs search <词> [--full] [--rules] # 检索:亮度排序+跨项目;--full 附正文,--rules 带铁律
126
126
  abs db status # 库体检:七层分布 / 悬挂链接 / 已入库项目
127
127
  abs db add # 把当前项目登记进 ~/.abs/projects.json
128
128
  abs db evolve # 知识进化体检:被推翻/断链/主题堆积/僵化页
129
- abs summon [--status] # 列出还没落页的会话(只读,不写盘)
129
+ abs ingest --list # 列出还没落页的会话(只读,不写盘)
130
130
  abs ingest <会话文件或 id> # 把一场 pi 会话落成会话页(session-id 双向回溯)
131
131
  # 已填 → 亮度×0.5;未填 → 整页不进检索
132
132
  abs resolve <页名或id> # 反查页面路径(页改名后 id 不变)
package/bin/abs.js CHANGED
@@ -151,6 +151,8 @@ const FLAG_SPEC = {
151
151
  'force': { type: 'boolean' },
152
152
  // ingest 用:--last 取最近一场会话(hook 在 session_shutdown 调,不用给 id);
153
153
  // --auto 只落真改过文件的会话(纯聊天不落页)。
154
+ // ingest 用:--list 只看「哪些会话没落页」不写入(原 abs summon 的职责)
155
+ 'list': { type: 'boolean' },
154
156
  'last': { type: 'boolean' },
155
157
  'auto': { type: 'boolean' },
156
158
  // db search 用:不输出 L1 铁律(查主题时它们是噪音;开工看铁律是 load 的职责)。
@@ -247,36 +249,51 @@ function parseArgv(args) {
247
249
  }
248
250
  return o;
249
251
  }
250
- const usage = `abs — 跨会话记忆工具(.brain/ 图谱)
252
+ const usage = `abs — 跨会话记忆(.brain/ 图谱)
251
253
 
252
- 常用:
253
- abs load 开工先跑: 读状态、接上次的活
254
- abs todo add X --note "做什么" 登记任务 (done/state/note 管后续)
255
- abs note "经验一句话" 随手记经验; abs log "完成 X" 记成果
256
- abs query <词…> 检索 .brain/; abs todo 看任务板
257
- abs recall [词…] 卡住时主动取相关页(不给词就按当前处境推)
254
+ 怎么用:按你要做的事找,不用记命令名。旧名都还能用,会自动提示新名。
258
255
 
259
- 全部命令 (细节: abs <命令> --help):
260
- init [--repair] 建图谱 (当前目录)
261
- load 开机读状态
262
- todo 任务看板 (add/note/state/done/archive)
263
- index | log | status 索引 / 流水 / 概要
264
- note <经验> [--tags a,b] 经验暂存 → sources/
265
- capture [--layer 1-6] "…" 按层判定落点后直接写入 (不传=L4; L7 用 abs note)
266
- summon [--mark|--status] 会话总结托底: 列出未总结/有更新的会话 (不写盘)
267
- ingest <会话文件> 把一场 pi 会话落成会话页 (带 session id 双向回溯)
268
- concept <slug> --title … 建概念页骨架
269
- query <词…> [--all] 检索知识页 (多词 OR)
270
- db rebuild|search|status|evolve 图数据库: 建库 / 亮度搜索 / 体检 / 进化体检
271
- resolve <页名…> 反查页面路径
272
- supersede <页名> [--by 页] 标经验已失效
273
- review [--accept 页…|--reject 页…] 待确认页队列 (draft 升 active/否决)
274
- lint 图谱体检 (死链/超限)
275
- rule [add "一句话"] 硬规则读写
276
- install|uninstall [--agent <宿主>] [--no-mcp] [--no-skill]
277
- update 升级并刷新 hook/skill
278
- config [set user <名字>] 使用者姓名 (写操作需先设置)
279
- --version | help 版本 / 本帮助
256
+ 【开工】读状态,接上次的活
257
+ abs load 必跑。Rules + 图谱计数 + 任务板 + 最近流水
258
+ abs recall [词…] 卡住时用:按当前处境推相关页(不给词也行)
259
+
260
+ 【找东西】
261
+ abs search <词> 推荐。亮度排序 + 邻居扩散 + 跨项目
262
+ abs query <词…> [--all] 要字面精确时用:多词 OR,输出更短
263
+ abs resolve <页名…> 有了页名,反查文件路径
264
+
265
+ 【写知识】(三者落点不同,别混)
266
+ abs note "一句话" 随手一句经验 → sources/ 待提炼
267
+ abs concept <slug> --title "…" 要成页 → concepts/(自动带四段骨架)
268
+ abs rule add "一句话" 要约束 → index 的 Rules(≤42 字)
269
+
270
+ 【写任务】
271
+ abs todo 看板
272
+ abs todo add <id> --note "做什么"
273
+ abs todo note <id> --note "进度" 追加子项
274
+ abs todo done <id> --as 落地|否决|仅方案 [结语]
275
+ abs todo state <id> --note 进行中|讨论中|滞留中|搁置
276
+ abs todo remove <id> 删除(连子项,不可恢复)
277
+
278
+ 【会话】
279
+ abs ingest [--list] 把一场 pi 会话落成结构化页;--list 看还有哪些没落
280
+
281
+ 【维护】
282
+ abs lint 体检:死链 / 超限 / 待提炼
283
+ abs review 待确认的 draft 页(升 active 或否决)
284
+ abs supersede <页> --by <新页> 标经验已失效(不删文件)
285
+ abs show index|log|status 看完整索引 / 流水 / 概要
286
+ abs db rebuild|status|evolve 重建库 / 库体检 / 进化体检
287
+ abs init [--repair] 建图谱
288
+
289
+ 【其它】
290
+ abs install|uninstall [--agent <宿主>] [--no-mcp] [--no-skill]
291
+ abs update 升级并刷新 hook/skill
292
+ abs config set user <名字> 使用者姓名(写操作需先设置)
293
+
294
+ 旧名(仍可用,会提示新名):
295
+ index/log/status → show | capture → concept | db search → search
296
+ summon → ingest --list | db add → init
280
297
 
281
298
  注: wrapup / teardown-check 是 hook 内部命令。
282
299
  `;
@@ -493,26 +510,70 @@ async function main() {
493
510
  rejectExtra(opts._, 'abs load');
494
511
  console.log(await cmdLoad({ dir: opts.dir }));
495
512
  break;
513
+ // 顶层 search(2026-11-06):MCP 侧一直叫 abs_search,CLI 却要敲 `db search` —— 名实不符。
514
+ // 现在 CLI 与 MCP 同名,`db search` 保留为别名(转指向这里并提示一次)。
515
+ case 'search': {
516
+ if (opts.help) {
517
+ console.log('abs search — 按亮度检索知识图谱\n\n'
518
+ + '用法: abs search <词> [--limit N] [--full] [--no-rules] [--rules]\n\n'
519
+ + ' 亮度排序 + 邻居扩散 + 跨项目(标 [跨项目])。\n'
520
+ + ' 默认省略 L1 铁律(开工时 abs load 会给);要看加 --rules。\n'
521
+ + ' --full 附正文;--limit 最多几页(默认 8)。\n\n'
522
+ + '与 abs query 的区别:这是详略两档不是重复 ——\n'
523
+ + ' query 给字面 OR 命中的页名+摘要,最省 token;\n'
524
+ + ' search 给亮度与扩散,找「我上次怎么解决 X」更准。');
525
+ break;
526
+ }
527
+ if (!opts._.length) throw new Error('✗ 缺检索词\n 用法: abs search <词> [--limit N] [--full] [--no-rules]');
528
+ // 顶层 search 默认省略铁律(2026-11-06):它是「查一个主题」的工具,
529
+ // 18 条铁律占输出近一半、每次都是噪音。要看铁律显式 --rules,或跑 abs load。
530
+ // db search 那个别名仍保持旧默认(带铁律),不改变老脚本的行为。
531
+ console.log(await cmdDbSearch({
532
+ query: opts._.join(' '), limit: Number(opts.limit) || 8,
533
+ full: !!opts.full, noRules: !opts.rules, cmdName: 'search',
534
+ }));
535
+ break;
536
+ }
496
537
  case 'recall': {
497
538
  const limit = Number(opts.limit) > 0 ? Number(opts.limit) : 5;
498
539
  console.log(await cmdRecall({ dir: opts.dir, terms: opts._, limit }));
499
540
  break;
500
541
  }
542
+ // ── 只读视图:统一到 abs show <视图>,旧名保留为别名并提示 ──
543
+ // (2026-11-06 命令面整理:index/log/status 都是「看某个文件的现状」,
544
+ // 三个顶层命令占三个名额却只有一个心智动作 —— 看。合成 show 一个入口。)
545
+ case 'show': {
546
+ const v = opts._[0];
547
+ if (!v) {
548
+ console.log('用法: abs show index|log|status\n index=完整索引 log=操作流水 status=图谱概要');
549
+ break;
550
+ }
551
+ rejectExtra(opts._.slice(1), 'abs show index|log|status');
552
+ if (v === 'status') console.log(await cmdStatus({ dir: opts.dir }));
553
+ else if (v === 'index' || v === 'log') console.log(await cmdShow({ dir: opts.dir, view: v }));
554
+ else console.log(`✗ 未知视图 "${v}"。可用: index | log | status`);
555
+ break;
556
+ }
501
557
  case 'index':
502
558
  rejectExtra(opts._, 'abs index');
559
+ console.log('(abs index 已改名 → abs show index)');
503
560
  console.log(await cmdShow({ dir: opts.dir, view: 'index' }));
504
561
  break;
505
562
  case 'log': {
506
563
  // 无参=查看 log.md;带参=追加一行 (用户/AI 主动记; hook 生命周期事件走技术日志 hooks.log, 不经这里)
564
+ // ★ log 有双职责,不能并进 show:带参时它是**写**操作。
565
+ // 所以只把「无参查看」这一半指向 show,写的那半保持不变、也不提示改名。
507
566
  if (opts._.length) {
508
567
  console.log(await cmdLog({ dir: opts.dir, title: opts._.join(' '), kind: opts.kind }));
509
568
  } else {
569
+ console.log('(查看流水已改名 → abs show log;abs log "完成 X" 仍是记成果)');
510
570
  console.log(await cmdShow({ dir: opts.dir, view: 'log' }));
511
571
  }
512
572
  break;
513
573
  }
514
574
  case 'status':
515
575
  rejectExtra(opts._, 'abs status');
576
+ console.log('(abs status 已改名 → abs show status)');
516
577
  console.log(await cmdStatus({ dir: opts.dir }));
517
578
  break;
518
579
  // abs todo —— 无子命令=看板;带子命令=任务写操作
@@ -572,10 +633,18 @@ async function main() {
572
633
  break;
573
634
  }
574
635
  case 'summon': {
636
+ // 别名(2026-11-06):并进 ingest --list —— 两者是同一件事的两半
637
+ // (summon 说「哪些没落页」,ingest 负责落)。分两个顶层命令只是多记一个词。
638
+ console.log('(abs summon 已改名 → abs ingest --list)');
575
639
  console.log(await cmdSummon({ dir: opts.dir, mark: !!opts.mark, full: !!opts.full, status: !!opts.status, limit: Number(opts.limit) || 15 }));
576
640
  break;
577
641
  }
578
642
  case 'ingest': {
643
+ // --list:只看「还有哪些会话没落页」,不写入(原来的 abs summon)
644
+ if (opts.list) {
645
+ console.log(await cmdSummon({ dir: opts.dir, mark: false, full: !!opts.full, status: false, limit: Number(opts.limit) || 15 }));
646
+ break;
647
+ }
579
648
  console.log(await cmdIngest({
580
649
  dir: opts.dir, file: opts._.join(' '), force: !!opts.force, mark: !!opts.mark,
581
650
  last: !!opts.last, auto: !!opts.auto, session: opts.session,
@@ -628,7 +697,13 @@ async function main() {
628
697
  const sub = opts._[0];
629
698
  const rest = opts._.slice(1);
630
699
  if (sub === 'rebuild') { console.log(await cmdDbRebuild({ dir: opts.dir })); break; }
631
- if (sub === 'search') { console.log(await cmdDbSearch({ query: rest.join(' '), limit: Number(opts.limit) || 8, full: !!opts.full, noRules: !!opts['no-rules'] })); break; }
700
+ if (sub === 'search') {
701
+ // 别名(2026-11-06):顶层已改叫 abs search。留着是为了不打断现有习惯与脚本,
702
+ // 但每次提示一次 —— 不提示的话旧名会永远活着,命令面又变宽。
703
+ console.log('(abs db search 已改名 → abs search)');
704
+ console.log(await cmdDbSearch({ query: rest.join(' '), limit: Number(opts.limit) || 8, full: !!opts.full, noRules: !!opts['no-rules'] }));
705
+ break;
706
+ }
632
707
  if (sub === 'status') { console.log(await cmdDbStatus()); break; }
633
708
  if (sub === 'evolve') { console.log(await cmdDbEvolve({ staleDays: Number(opts.staleDays) || 90 })); break; }
634
709
  if (sub === 'add') {
@@ -33,11 +33,71 @@ export function stripAnsi(s: string): string {
33
33
  return s.replace(/\x1b\[[0-9;]*m/g, '')
34
34
  }
35
35
 
36
+ /** 终端显示宽度:CJK/全角记 2 列,其余记 1 列。
37
+ *
38
+ * ★ 为何必需(2026-11-07 用户实报「输入框右下角底下的线冒出来了」):
39
+ * 之前 roundBorder 按**字符数**算宽度,而左下角标签里有中文昵称
40
+ * (如「转发锦鲤求暴富」7 个汉字实际占 14 列)—— 少算了 7 列,
41
+ * 多出的宽度就把右下角圆角挤出了屏幕。
42
+ * 右侧状态栏一直没出问题是因为它只有 ASCII。
43
+ *
44
+ * 与 abs.pi.ts 的 dispWidth 同规则,但不共享(两个扩展各自独立安装)。 */
45
+ export function textWidth(s: string): number {
46
+ const plain = stripAnsi(s)
47
+ let w = 0
48
+ for (const ch of plain) {
49
+ const cp = ch.codePointAt(0) || 0
50
+ const wide =
51
+ (cp >= 0x1100 && cp <= 0x115f) ||
52
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
53
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
54
+ (cp >= 0xf900 && cp <= 0xfaff) ||
55
+ (cp >= 0xfe30 && cp <= 0xfe6f) ||
56
+ (cp >= 0xff00 && cp <= 0xff60) ||
57
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
58
+ (cp >= 0x1f300 && cp <= 0x1f64f) ||
59
+ (cp >= 0x1f900 && cp <= 0x1f9ff)
60
+ w += wide ? 2 : 1
61
+ }
62
+ return w
63
+ }
64
+
65
+ /** 从尾部去掉 n 列。边框行只由 ASCII `─`(U+2500,各 1 列)与空格组成,
66
+ * 每字符 1 列,故直接按字符截。
67
+ * ★ 不要在这里用宽字符判断 —— 之前写 `cp >= 0x1100` 把 U+2500 当成了 2 列,
68
+ * 导致削掉一半、边框变长(2026-11-07 踩过)。 */
69
+ function dropCols(s: string, n: number): string {
70
+ if (n <= 0) return s
71
+ return s.slice(0, Math.max(0, s.length - n))
72
+ }
73
+
36
74
  /**
37
- * 把一行边框文本换成「左圆角 + 中段 + 右圆角」,可见宽度保持原样。
75
+ * 推理强度 → 主题色 token。空串表示「不上色,沿用默认色」。
76
+ *
77
+ * 规则:`thinking` + 首字母大写,正好对上主题里的 thinkingMinimal…thinkingMax 七档。
78
+ * 为什么 off 返回空串而不是 `thinkingOff`:主题给 thinkingOff 的是接近背景的暗灰
79
+ * (dark.json: `okhsl(229 8% 49%)`),拿它去染文字会看不清。用户 2026-11-06 定的是
80
+ * 「off 时保持默认色」。
81
+ *
82
+ * ★ 同一份逻辑在 hooks/abs.pi.ts 里也有一份(`effortColorToken`)。不抽公共模块是故意的:
83
+ * 安装后 `abs.ts` 与 `abs-rounded-editor/` 是两个可独立删除的扩展,抽成共享文件会让
84
+ * 「删掉输入框样式目录」连带把 todo 面板搞崩。
85
+ */
86
+ export function thinkingColorToken(level: string | null | undefined): string {
87
+ const l = String(level || '').trim()
88
+ if (!l || l === 'off') return ''
89
+ return 'thinking' + l[0].toUpperCase() + l.slice(1)
90
+ }
91
+
92
+ /**
93
+ * 把一行边框文本换成「左圆角 + 左标签 + 中段 + 右圆角」,**显示宽度**保持原样。
38
94
  * 边框行内含 ANSI 时直接 slice 会切坏转义序列,所以先剥色、改字、再整行着色。
39
95
  *
40
- * label 贴在最右端;放不下就丢掉 label(宁可没有,也不让边框溢出折行)。
96
+ * ★ 宽度一律按 textWidth(CJK = 2 列)算,不按字符数 —— 标签里的中文昵称
97
+ * 按字符数算会少算一半,多出的宽度把右下角圆角挤到屏幕外(2026-11-07 实报)。
98
+ *
99
+ * leftLabel 贴在左圆角之后;label 贴最右端。哪个放不下就丢掉哪个
100
+ * (宁可没有,也不让边框溢出折行)。
41
101
  */
42
102
  export function roundBorder(
43
103
  line: string,
@@ -45,16 +105,32 @@ export function roundBorder(
45
105
  left: string,
46
106
  right: string,
47
107
  label = '',
108
+ leftLabel = '',
48
109
  ): string {
49
- // 宽度按字符数算(不是显示宽度)。成立的前提:line 只含 ASCII 的 `─` 与空格
50
- // —— pi 的边框行正是如此。若将来边框里出现中日韩宽字符,这里要改用 visibleWidth。
51
110
  const plain = stripAnsi(line)
52
111
  if (plain.length === 0) return line
53
- // 原边框行宽 = width-2;两端圆角各占 1 列,合起来正好 width。
112
+ // mid 是两端圆角之间的那段;总宽 = 1(左圆角) + mid 宽 + 1(右圆角)。
54
113
  let mid = plain
55
- // label 两侧各留 1 空格;放不下就不要 label。
56
- if (label && label.length + 2 < mid.length) {
57
- mid = mid.slice(0, mid.length - label.length - 2) + ' ' + label + ' '
114
+
115
+ // 右 label 先做(从右端拿 `─` 换),再做左侧 —— 否则两侧都从右端切,位置会互相错。
116
+ // 注意:剥掉 **lw** 列后接 ` label `,两者宽度相等,故总宽不变。
117
+ if (label) {
118
+ const lw = textWidth(label) + 2
119
+ if (lw < textWidth(mid)) mid = dropCols(mid, lw) + ' ' + label + ' '
120
+ }
121
+
122
+ // 左 label 插到最左端(圆角之后,即 working 图标之前)。
123
+ // ★ 从**中间**剥等长的 `─`,不能从右端剥 —— 右端可能已经是右 label,
124
+ // 再从右端切就会把它末尾拼掉(实测:「8.0%/1.0M m」变成「8.0」)。
125
+ // 两侧各留 1 空格:否则 `╰fanchao` 贴死圆角,看着像缺了一角。
126
+ if (leftLabel) {
127
+ const lw = textWidth(leftLabel) + 2
128
+ // 右 label 已占的尾巴宽度:保留它,从它之前切。
129
+ const tailLen = label && textWidth(label) + 2 < textWidth(mid) ? textWidth(label) + 2 : 0
130
+ if (lw + tailLen < textWidth(mid)) {
131
+ const body = dropCols(mid.slice(0, mid.length - tailLen), lw)
132
+ mid = ' ' + leftLabel + ' ' + body + mid.slice(mid.length - tailLen)
133
+ }
58
134
  }
59
135
  return fg(left + mid + right)
60
136
  }
@@ -18,13 +18,41 @@
18
18
  * 注意:自动补全菜单行排在底边之下、且自带内容宽度,不套竖线(否则菜单被框住会错位)。
19
19
  */
20
20
  import { CustomEditor } from '@earendil-works/pi-coding-agent'
21
- import { buildStatus, roundBorder, shortenModel } from './format.ts'
21
+ import { readFile } from 'node:fs/promises'
22
+ import { homedir } from 'node:os'
23
+ import { join } from 'node:path'
24
+ import { buildStatus, roundBorder, shortenModel, thinkingColorToken } from './format.ts'
25
+ // 昵称池跟 todo 面板共用一份 —— 2026-11-07 用户要求把「作者名 · 随机语」从面板
26
+ // 搬到输入框左下角,两边必须用同一个池才叫「搬」。
27
+ // 为何用 ../abs.ts:安装后两者分别在 ~/.pi/agent/extensions/abs.ts 与
28
+ // extensions/abs-rounded-editor/index.ts,相对路径稳定。
29
+ // 代价:删掉 abs.ts 会让本扩展导入失败 —— 可接受,因为 abs.ts 是主扩展,
30
+ // 输入框样式是它的附属;单独删主扩展就不是合法使用形态了。
31
+ import { pickNickname } from '../abs.ts'
22
32
 
23
33
  const SIDE = '│'
24
34
 
25
35
  /** 右上角状态文本,如 `8.0%/1.0M deepseek-v4.1-flash`。render() 拿不到 ctx,故由事件回调维护。 */
26
36
  let statusLabel = ''
27
37
 
38
+ /** 左下角标签:`fanchao · 昵称`(2026-11-07 用户要求从面板搬到这里)。
39
+ * session_start 时算一次 —— 不能每帧抽,否则每帧重绘就是个疯狂滚动的老虎机。
40
+ * 空串 = 不显示(无用户名也无昵称时)。 */
41
+ let cornerLabel = ''
42
+
43
+ /** 当前推理强度(off/minimal/low/medium/high/xhigh/max)。render() 拿不到 ctx,同样这里存。
44
+ * 用途:输入文字随推理强度变色(2026-11-06 用户需求)。 */
45
+ let effortLevel = ''
46
+
47
+ /** 取当前主题实例的 getter。
48
+ *
49
+ * ★ 为何不能把主题从构造函数里拿:pi 传给编辑器了的是 `getEditorTheme()`,
50
+ * 那对象只有 `{ borderColor, selectList }`,**没有 `fg`** —— 直接用它上色会静默无效
51
+ * (不报错,只是没颜色)。真正带 fg 的是 `ctx.ui.theme`(interactive-mode 的 getter)。
52
+ * ★ 为何存 getter 而不是存实例:`/theme` 切主题后 ctx.ui.theme 返回**新实例**,
53
+ * 存旧实例会継续用旧主题的色板。 */
54
+ let themeGetter: (() => any) | null = null
55
+
28
56
  export class RoundedEditor extends CustomEditor {
29
57
  render(width: number): string[] {
30
58
  if (width < 4) return super.render(width)
@@ -34,6 +62,11 @@ export class RoundedEditor extends CustomEditor {
34
62
 
35
63
  const borderColor = (this as any).borderColor?.bind(this)
36
64
  const wrap = (s: string) => (borderColor ? borderColor(s) : s)
65
+ // 文字色 = 推理强度色;off(或取不到)时为空 → 不着色,用终端默认前景色。
66
+ const token = thinkingColorToken(effortLevel)
67
+ const themeNow = themeGetter?.()
68
+ const effortFg =
69
+ token && typeof themeNow?.fg === 'function' ? (s: string) => themeNow.fg(token, s) : null
37
70
  const out: string[] = []
38
71
 
39
72
  // 上边框:原本是整行 `─`(含 working 图标与滚动提示),两端换圆角,右端贴状态。
@@ -43,15 +76,47 @@ export class RoundedEditor extends CustomEditor {
43
76
  const bottomIdx = lines.length - 1 - autocompleteHeight
44
77
 
45
78
  for (let i = 1; i < bottomIdx; i++) {
46
- out.push(wrap(SIDE) + lines[i] + wrap(SIDE))
79
+ const body = lines[i]
80
+ // 推理强度色染**输入文字**(2026-11-06 用户)。边框不动 —— 边框本来就是 pi 按
81
+ // 同一档强度上的色(interactive-mode 的 updateEditorBorderColor),两者自然同色。
82
+ // 整行包裹而不逐字:光标由 \x1b[7m 反转标记,与前景色互不干扰;
83
+ // 输入框内容是用户手打的纯文本,行内不会有 reset 把后半行冲掉。
84
+ out.push(wrap(SIDE) + (effortFg ? effortFg(body) : body) + wrap(SIDE))
47
85
  }
48
- out.push(roundBorder(lines[bottomIdx], wrap, '╰', '╯'))
86
+ // 下边框:作者名 + 昵称贴左侧(2026-11-07 用户要求)。
87
+ out.push(roundBorder(lines[bottomIdx], wrap, '╰', '╯', '', cornerLabel))
49
88
  // 自动补全行原样透传(不套竖线)。
50
89
  for (let i = bottomIdx + 1; i < lines.length; i++) out.push(lines[i])
51
90
  return out
52
91
  }
53
92
  }
54
93
 
94
+ /** 读出作者名:与 abs.pi.ts 的 currentUser 同规则(ABS_USER > ~/.abs/config.json)。
95
+ * 不在该文件导出 currentUser 是因为它依赖更多模块级状态,且本处只需这两行。 */
96
+ async function readUser(): Promise<string> {
97
+ const env = String(process.env.ABS_USER || '').trim()
98
+ if (env) return env
99
+ try {
100
+ const dir = process.env.ABS_CONFIG_DIR || join(homedir(), '.abs')
101
+ const raw = await readFile(join(dir, 'config.json'), 'utf8')
102
+ const u = JSON.parse(raw).user
103
+ return typeof u === 'string' ? u.trim() : ''
104
+ } catch {
105
+ return ''
106
+ }
107
+ }
108
+
109
+ /** 算出左下角标签:`fanchao · 昵称`。缺哪段就省哪段,都为空格返回空串(不画)。 */
110
+ async function refreshCornerLabel(): Promise<void> {
111
+ try {
112
+ const who = await readUser()
113
+ const nick = pickNickname()
114
+ cornerLabel = [who, nick].filter(Boolean).join(' · ')
115
+ } catch {
116
+ cornerLabel = ''
117
+ }
118
+ }
119
+
55
120
  /** 刷新右上角状态文本(每次渲染前由事件与定时器调用)。 */
56
121
  function refreshStatus(ctx: any): void {
57
122
  try {
@@ -59,12 +124,29 @@ function refreshStatus(ctx: any): void {
59
124
  } catch {
60
125
  statusLabel = shortenModel(ctx?.model?.id ?? '')
61
126
  }
127
+ refreshEffort(ctx)
128
+ }
129
+
130
+ /** 读出当前推理强度。`ctx.thinkingLevel` 是 pi 官方给扩展的入口(effective level)。 */
131
+ function refreshEffort(ctx: any): void {
132
+ try {
133
+ effortLevel = String(ctx?.thinkingLevel || '')
134
+ } catch {
135
+ effortLevel = ''
136
+ }
62
137
  }
63
138
 
64
139
  export default function piRoundedEditor(pi: any): void {
65
140
  pi.on('session_start', (_event: unknown, ctx: any) => {
66
141
  if (!ctx?.ui?.setEditorComponent) return
142
+ // 主题 getter(带 fg 的那个)—— 见 themeGetter 注释。
143
+ themeGetter = () => ctx.ui?.theme
67
144
  refreshStatus(ctx)
145
+ // 左下角标签:每次会话开一次(含 reload/new/resume),会话内固定不变。
146
+ // 与面板旧的昵称行为一致 —— 用户要的是「搬过去」,不是改规则。
147
+ refreshCornerLabel()
148
+ .then(() => ctx.ui?.requestRender?.())
149
+ .catch(() => {})
68
150
  // 第四个参数 embedWorkingStatus: true —— working 状态进上边框,不再单独占一行。
69
151
  ctx.ui.setEditorComponent(
70
152
  (tui: any, theme: any, keybindings: any) =>
@@ -80,6 +162,14 @@ export default function piRoundedEditor(pi: any): void {
80
162
  ctx?.ui?.requestRender?.()
81
163
  })
82
164
 
165
+ // 切推理强度时重刷输入框文字色(2026-11-06 用户需求)。
166
+ // 为何不能只靠 session_start 那一次:/model 的强度档位、Alt+T 之类的切换不改会话,
167
+ // 不重建编辑器也就不会重新读 effortLevel —— 文字会停在旧色。
168
+ pi.on('thinking_level_select', (_event: any, ctx: any) => {
169
+ refreshEffort(ctx)
170
+ ctx?.ui?.requestRender?.()
171
+ })
172
+
83
173
  // 上下文占用随每轮对话变化 —— 轮次结束与消息结束时刷新。
84
174
  for (const ev of ['turn_end', 'message_end', 'tool_execution_end']) {
85
175
  pi.on(ev, (_event: any, ctx: any) => {
package/hooks/abs.omp.ts CHANGED
@@ -503,11 +503,15 @@ function clipToWidth(s: string, width: number): string {
503
503
 
504
504
  // 「进行中」行的迷你动画:三个小符号依次由空心变实心。
505
505
  // 帧宽固定 3 列 —— 宽度不变才不会让面板每帧抖动。
506
- const TODO_ANIM_FRAMES = ['○○○', '●○○', '●●○', '●●●', '●●○', '●○○']
507
- const TODO_ANIM_MS = 250
506
+ const TODO_ANIM_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
507
+ const TODO_ANIM_MS = 100
508
508
 
509
509
  /** 动画相位(每 tick +1)。模块级 —— 闭包 flag 在重复注册时不共享。 */
510
510
  let animPhase = 0
511
+
512
+ /** 当前推理强度。模块级理由同上:widget factory 拿不到 ctx。
513
+ * omp 宿主有无这个字段未验证 —— 取不到就是空串,面板退回 accent,行为与以前一致。 */
514
+ let effortLevel = ''
511
515
  let animTimer: ReturnType<typeof setInterval> | undefined
512
516
 
513
517
  /** 启动/保持动画定时器。重复调用不会叠加(已存在则不动)。
@@ -532,6 +536,21 @@ export function todoAnimFrame(phase: number): string {
532
536
  return TODO_ANIM_FRAMES[i]
533
537
  }
534
538
 
539
+ /**
540
+ * 推理强度 → 主题色 token。空串表示「不上推理色,用兼底色」。
541
+ *
542
+ * 规则:`thinking` + 首字母大写(thinkingMinimal…thinkingMax)。
543
+ * off 返回空串而不是 `thinkingOff`:那个色接近背景,染文字会看不清(2026-11-06 用户定)。
544
+ *
545
+ * ★ 与 pi 版(hooks/abs.pi.ts 的 effortToken)逐字同义,但不共享:两个模板分别装到不同宿主,
546
+ * 独立成文件才能各自删除/升级。
547
+ */
548
+ export function effortToken(level: string | null | undefined): string {
549
+ const l = String(level || '').trim()
550
+ if (!l || l === 'off') return ''
551
+ return 'thinking' + l[0].toUpperCase() + l.slice(1)
552
+ }
553
+
535
554
  export function renderPanelLines(
536
555
  data: { total: number; rows: { state: string; id: string; desc: string; kids?: { done: boolean; text: string }[] }[]; hidden: number },
537
556
  who: string,
@@ -539,38 +558,48 @@ export function renderPanelLines(
539
558
  fg: (color: string, s: string) => string,
540
559
  animPhase = -1,
541
560
  nickname = '',
561
+ effort = '',
542
562
  ): string[] {
543
563
  // ★ 空看板不返回空数组:面板是「机制在不在」的常驻指示,进去就看不见 = 不知道机制活着。
544
- const colorOf = (state: string): string => (state === '搁置' ? 'dim' : state === '滞留中' ? 'muted' : state === '讨论中' ? 'dim' : 'accent')
564
+ // ★ 主色跟推理强度走(2026-11-06 与 pi 版同步):标题、[状态]、树线、复选框、
565
+ // 进行中动画同用一色,与输入框描边同色系。
566
+ // ★ off 时整块面板用灰阶:高亮色只在真的开着推理时给。
567
+ // 主色 muted(中灰),描述与子项降一阶到 dim(深灰),两阶保留层次。
568
+ // omp 宿主目前不传 effort → 走 off 分支,面板为灰阶。
569
+ const off = !effortToken(effort)
570
+ const main = effortToken(effort) || 'muted'
571
+ const flow = off ? 'dim' : 'muted'
545
572
  const lines: string[] = []
546
573
  // 上描边行已删除(2026-11-04 与 pi 版同步,用户要求「去掉上边框」)。首行现在是标题行。
547
- const whoPart = who ? who + (nickname ? ` · ${nickname}` : '') : nickname
548
- const title = whoPart ? `Todo (${data.total}) — ${whoPart}` : `Todo (${data.total})`
549
- // 空看板标题前加 1 格(与输入框文字对齐);有任务时不加,任务行顶到最左(2026-11-04 用户)。
550
- lines.push(clipToWidth((data.total === 0 ? ' ' : '') + fg('accent', title), width))
574
+ // 标题只有计数(2026-11-07 与 pi 版同步):作者名与昵称已搬到输入框左下角,
575
+ // 面板不再重复占宽度。**不要再往标题后拼作者名/昵称**。
576
+ const title = `Todo (${data.total})`
577
+ // 标题顶最左(2026-11-04 用户)。旧代码空看板时多缩 1 格,2026-11-06 与 pi 版同步删去 ——
578
+ // 空看板已整块不显示(见 refreshTodoPanel)。
579
+ lines.push(clipToWidth(fg(main, title), width))
551
580
 
552
581
  const lastIdx = data.rows.length - 1
553
- // 空看板只显示标题行(2026-11-04 用户要求去掉「(无未完成任务)」那行)。
554
- if (data.total === 0) return lines
555
582
  data.rows.forEach((t, i) => {
556
583
  // `└─` 只给最后一条 —— 不管它有没有子项(否则末条带子项时全篇没有收尾)。
557
584
  const isLast = i === lastIdx
558
585
  const branch = isLast ? '└─' : '├─'
559
- const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg('accent', todoAnimFrame(animPhase)) : ''
586
+ // 单字符 spinner 紧跟 [进行中](2026-11-06 与 pi 版同步)。
587
+ const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg(main, todoAnimFrame(animPhase)) : ''
560
588
  // 复选框 `[ ]`:面板是 todo.md 的只读视图,就该长成 todo 原貌。
561
- const box = fg('dim', '[ ]')
562
- const head = `${fg('dim', branch)} ${box} ${fg(colorOf(t.state), `[${t.state}]`)} ${fg('text', t.id)}${anim}`
563
- const desc = t.desc ? ` ${fg('dim', '—')} ${fg('muted', t.desc)}` : ''
589
+ const box = fg(main, '[ ]')
590
+ // 状态标签统一跟主色(2026-11-06 与 pi 版同步:靠符号和文字区分状态,不靠颜色)。
591
+ const head = `${fg(main, branch)} ${box} ${fg(main, `[${t.state}]`)}${anim} ${fg('text', t.id)}`
592
+ const desc = t.desc ? ` ${fg('dim', '—')} ${fg(flow, t.desc)}` : ''
564
593
  lines.push(clipToWidth(head + desc, width))
565
594
  if (t.kids && t.kids.length) {
566
595
  // 子项:标准 md 嵌套列表 → 画成树
567
- const stem = isLast ? ' ' : fg('dim', '│ ')
596
+ const stem = isLast ? ' ' : fg(main, '│ ')
568
597
  const n = t.kids.length
569
598
  t.kids.forEach((k, ki) => {
570
599
  const kb = ki === n - 1 ? '└─' : '├─'
571
- const kbox = fg('dim', k.done ? '[x]' : '[ ]')
572
- const ktext = fg(k.done ? 'dim' : 'muted', k.text)
573
- lines.push(clipToWidth(`${stem} ${fg('dim', kb)} ${kbox} ${ktext}`, width))
600
+ const kbox = fg(main, k.done ? '[x]' : '[ ]')
601
+ const ktext = fg(k.done ? 'dim' : flow, k.text)
602
+ lines.push(clipToWidth(`${stem} ${fg(main, kb)} ${kbox} ${ktext}`, width))
574
603
  })
575
604
  }
576
605
  })
@@ -597,6 +626,9 @@ async function refreshTodoPanel(ui: ExtensionUIContext | undefined, cwd: string)
597
626
  const md = await readFile(join(cwd, '.brain', 'todo.md'), 'utf8')
598
627
  const who = await currentUser()
599
628
  const data = parseOpenTasks(md, PANEL_MAX_ROWS, who)
629
+ // ★ 无任务就不显示面板(2026-11-06 与 pi 版同步,推翻了「空看板也画标题行」)。
630
+ // **不要再改回空看板也显示** —— 用户刚把那个行为提掉。
631
+ if (data.total === 0) { hidePanel(ui); return }
600
632
  let tuiRef: RenderTui | null = null
601
633
  ui.setWidget(
602
634
  PANEL_KEY,
@@ -605,7 +637,7 @@ async function refreshTodoPanel(ui: ExtensionUIContext | undefined, cwd: string)
605
637
  lastTui = tui // 供 hidePanel 在「无动画」时也能请求重绘
606
638
  return {
607
639
  render: (width: number) =>
608
- renderPanelLines(data, who, width, (c: string, s: string) => theme.fg(c, s), animPhase, sessionNickname),
640
+ renderPanelLines(data, who, width, (c: string, s: string) => theme.fg(c, s), animPhase, sessionNickname, effortLevel),
609
641
  invalidate: () => {},
610
642
  }
611
643
  },
@@ -736,7 +768,8 @@ export default function absOmpExtension(pi: ExtensionAPI): void {
736
768
  resetThrottle()
737
769
  // 本会话的随机昵称 —— 在这里抽一次(不是每帧抽,否则面板会疯狂闪)。
738
770
  sessionNickname = pickNickname()
739
- logHook(`session_start nickname=${sessionNickname || '(off)'}`).catch(() => {})
771
+ effortLevel = String((ctx && (ctx as any).thinkingLevel) || '')
772
+ logHook(`session_start nickname=${sessionNickname || '(off)'} effort=${effortLevel || '(off)'}`).catch(() => {})
740
773
  // 启动时就画出面板。时序:omp 在 session_start 时已给 ctx.ui(无 UI 时是 noOp)。
741
774
  // ⚠ omp 的 session_start 事件**没有 reason 字段**(pi 有)—— 别照抄 pi 版的 reason= 。
742
775
  const cwd = (ctx && ctx.cwd) || process.cwd()
@@ -789,6 +822,14 @@ export default function absOmpExtension(pi: ExtensionAPI): void {
789
822
  refreshTodoPanel(ctx.ui, ctx.cwd).catch(() => {})
790
823
  })
791
824
 
825
+ // 切推理强度 → 面板主色跟着变(2026-11-06 与 pi 版同步)。
826
+ // omp 是否发这个事件尚未验证 —— 不发就只是一个空订阅,不影响任何现有行为。
827
+ pi.on("thinking_level_select", (event: any, ctx: any) => {
828
+ effortLevel = String(event?.level || (ctx && ctx.thinkingLevel) || "")
829
+ logHook(`thinking_level_select level=${effortLevel || "(off)"}`).catch(() => {})
830
+ refreshTodoPanel(ctx?.ui, (ctx && ctx.cwd) || process.cwd()).catch(() => {})
831
+ })
832
+
792
833
  // 往 system prompt 注入静态指引 —— **omp 的通道与 pi 不同**:
793
834
  // omp 返回 {systemPrompt: string[]} 是**整体替换**(不是 pi 的追加式 guidelines),
794
835
  // 故必须「event.systemPrompt 基线 + 我们的段」拼接后整体返回。