@fanchao8609/agent_brain_sync 1.29.0 → 1.31.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
@@ -114,21 +114,16 @@ abs todo note TASK-1 --note "改到 X 文件 L40" # 追加一条子项(
114
114
  abs todo child-done TASK-1 [--n 2] # 勾子项(n=第几条,缺省最后一条)
115
115
  abs todo child-undone TASK-1 [--n 2] # 取消勾(勾错了退回)
116
116
  abs todo memo TASK-1 "随手记一段(可多句,不上看板)" # 任务笔记 → sessions/;无参=查看
117
- abs todo state TASK-1 --note "进行中|讨论中|滞留中" # 改行首状态标记
117
+ abs todo state TASK-1 --note "进行中|讨论中|滞留中|搁置" # 改行首状态标记
118
+ abs todo state TASK-1 --note "滞留中" --n 2 # 改第 2 条子项的状态(子项也能标四态)
118
119
  abs todo done TASK-1
119
120
 
120
121
  abs note "一句话经验" --tags 坑,docker # 经验暂存 → sources/
121
122
  abs log "完成 X" # 记一行流水;abs log 无参 = 查看
122
- abs query <词> # 检索图谱(多词 OR);已被推翻的经验默认隐藏(--all 可看)
123
+ abs query <词> # 检索图谱(多词 OR 扫全 .brain/);已被推翻的经验默认隐藏(--all 可看)
123
124
  abs capture [--layer 1-6] [--kind …] "一句话" # 按七层判层后落到位(L7 用 abs note;--kind: gotcha|decision|tradeoff|howto)
124
- abs db rebuild # 扫所有登记项目 → 建图数据库(~/.abs/graph.db)
125
- abs search <词> [--full] [--rules] # 检索:亮度排序+跨项目;--full 附正文,--rules 带铁律
126
- abs db status # 库体检:七层分布 / 悬挂链接 / 已入库项目
127
- abs db add # 把当前项目登记进 ~/.abs/projects.json
128
- abs db evolve # 知识进化体检:被推翻/断链/主题堆积/僵化页
129
125
  abs ingest --list # 列出还没落页的会话(只读,不写盘)
130
- abs ingest <会话文件或 id> # 把一场 pi 会话落成会话页(session-id 双向回溯)
131
- # 已填 → 亮度×0.5;未填 → 整页不进检索
126
+ abs ingest <会话文件或 id> # 把一场 pi 会话摘要并进当天的 log-<日期>.md(## 会话 <id8> 段)
132
127
  abs resolve <页名或id> # 反查页面路径(页改名后 id 不变)
133
128
  abs supersede <页名> [--by <新页>] # 标记经验已失效(不删文件,保留历史;query 默认不再返回)
134
129
  abs status # 当前项目 + 图谱概要
package/bin/abs.js CHANGED
@@ -2,8 +2,7 @@
2
2
  // bin/abs.js — abs CLI 入口。
3
3
  // abs <cmd> [args]
4
4
  // 命令: init / board / status / load / task / install / uninstall / help
5
- import { cmdInit, cmdStatus, cmdLoad, cmdRecall, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede, cmdReview } from '../src/store.js';
6
- import { cmdDbRebuild, cmdDbSearch, cmdDbStatus, cmdDbEvolve, addProject } from '../src/brain/db-cmd.js';
5
+ import { cmdInit, cmdStatus, cmdLoad, cmdTask, cmdLog, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdShow, cmdRepair, cmdWrapup, cmdRule, cmdTeardownCheck, cmdTodoArchive, cmdResolve, cmdSupersede, cmdReview } from '../src/store.js';
7
6
  import { cmdCapture } from '../src/brain/capture.js';
8
7
  import { cmdSummon } from '../src/brain/summon-cmd.js';
9
8
  import { cmdIngest } from '../src/brain/ingest.js';
@@ -340,15 +339,13 @@ function parseArgv(args) {
340
339
  }
341
340
  const usage = `abs — 跨会话记忆(.brain/ 图谱)
342
341
 
343
- 怎么用:按你要做的事找。相近命令已折叠,旧别名已删(index/status/summon/db search → show/search/ingest)。
342
+ 怎么用:按你要做的事找。相近命令已折叠,旧别名已删(index/status/summon → show/ingest)。
344
343
 
345
344
  【开工】读状态,接上次的活
346
345
  abs load 必跑。未完成 todo + 用法 + 最近日志
347
- abs recall [词…] 卡住时用:按当前处境推相关页(不给词也行)
348
346
 
349
347
  【找东西】
350
- abs search <词> 推荐。亮度排序 + 邻居扩散 + 跨项目
351
- abs query <词…> [--all] 要字面精确时用:多词 OR,输出更短
348
+ abs query <词…> [--all] 多词 OR 搜全图,给页名+摘要(最省 token)
352
349
  abs resolve <页名…> 有了页名,反查文件路径
353
350
 
354
351
  【写知识】(三者落点不同,别混)
@@ -372,7 +369,6 @@ const usage = `abs — 跨会话记忆(.brain/ 图谱)
372
369
  abs review 待确认的 draft 页(升 active 或否决)
373
370
  abs supersede <页> --by <新页> 标经验已失效(不删文件)
374
371
  abs show index|log|status 看完整索引 / 流水 / 概要
375
- abs db rebuild|status|evolve 重建库 / 库体检 / 进化体检
376
372
  abs init [--repair] 建图谱
377
373
 
378
374
  【其它】
@@ -454,7 +450,7 @@ const subUsage = {
454
450
  ' abs todo child-done <id> [--n N] 勾子项(缺省最后一条)',
455
451
  ' abs todo child-undone <id> [--n N] 取消勾',
456
452
  ' abs todo memo <id> "随手记(可多句)" 任务笔记 → sessions/,不上看板;无文字=查看',
457
- ' abs todo state <id> --note 进行中|讨论中|滞留中|搁置',
453
+ ' abs todo state <id> --note 进行中|讨论中|滞留中|搁置 [--n N] --n = 改第 N 条子项(缺省改任务本身)',
458
454
  ' abs todo remove <id> 删除任务(连子项,不可恢复;只是先不做请用 state 搁置)',
459
455
  ' abs todo done <id> [--as 落地|否决|仅方案] [结语文字]',
460
456
  ' abs todo archive [--keep-days N] [--dry-run]',
@@ -485,18 +481,6 @@ const subUsage = {
485
481
  '参数:',
486
482
  ' --all 连 superseded(已失效)页一起返回',
487
483
  ].join('\n'),
488
- recall: [
489
- 'abs recall — 卡住时主动取相关页(不需先想到检索词)',
490
- '',
491
- '用法:',
492
- ' abs recall [词1 词2 …] [--limit N]',
493
- '',
494
- '不给词时,从当前处境推:活跃 todo 的关键词 + 最近改过的文件名。',
495
- '给了词就只用你给的词,不混处境词。',
496
- '',
497
- '与 load 的区别: load 只在开工跑一次;recall 随时可跑(卡住/换方向时)。',
498
- '与 query 的区别: query 枚全图命中;recall 只给按亮度亮的几页。',
499
- ].join('\n'),
500
484
  config: [
501
485
  'abs config — 使用者姓名(写操作的作者标记)',
502
486
  '',
@@ -597,35 +581,6 @@ async function main() {
597
581
  rejectExtra(opts._, 'abs load');
598
582
  console.log(await cmdLoad({ dir: opts.dir }));
599
583
  break;
600
- // 顶层 search(2026-11-06):MCP 侧一直叫 abs_search,CLI 却要敲 `db search` —— 名实不符。
601
- // 现在 CLI 与 MCP 同名,`db search` 保留为别名(转指向这里并提示一次)。
602
- case 'search': {
603
- if (opts.help) {
604
- console.log('abs search — 按亮度检索知识图谱\n\n'
605
- + '用法: abs search <词> [--limit N] [--full] [--no-rules] [--rules]\n\n'
606
- + ' 亮度排序 + 邻居扩散 + 跨项目(标 [跨项目])。\n'
607
- + ' 默认省略 L1 铁律(开工时 abs load 会给);要看加 --rules。\n'
608
- + ' --full 附正文;--limit 最多几页(默认 8)。\n\n'
609
- + '与 abs query 的区别:这是详略两档不是重复 ——\n'
610
- + ' query 给字面 OR 命中的页名+摘要,最省 token;\n'
611
- + ' search 给亮度与扩散,找「我上次怎么解决 X」更准。');
612
- break;
613
- }
614
- if (!opts._.length) throw new Error('✗ 缺检索词\n 用法: abs search <词> [--limit N] [--full] [--no-rules]');
615
- // 顶层 search 默认省略铁律(2026-11-06):它是「查一个主题」的工具,
616
- // 18 条铁律占输出近一半、每次都是噪音。要看铁律显式 --rules,或跑 abs load。
617
- // db search 那个别名仍保持旧默认(带铁律),不改变老脚本的行为。
618
- console.log(await cmdDbSearch({
619
- query: opts._.join(' '), limit: Number(opts.limit) || 8,
620
- full: !!opts.full, noRules: !opts.rules, cmdName: 'search',
621
- }));
622
- break;
623
- }
624
- case 'recall': {
625
- const limit = Number(opts.limit) > 0 ? Number(opts.limit) : 5;
626
- console.log(await cmdRecall({ dir: opts.dir, terms: opts._, limit }));
627
- break;
628
- }
629
584
  // ── 只读视图:统一到 abs show <视图>,旧名保留为别名并提示 ──
630
585
  // (2026-11-06 命令面整理:index/log/status 都是「看某个文件的现状」,
631
586
  // 三个顶层命令占三个名额却只有一个心智动作 —— 看。合成 show 一个入口。)
@@ -677,7 +632,7 @@ async function main() {
677
632
  // memo 同:随手记常是多句带空格,位置参数收全段比 --note 顺手。
678
633
  const isDone = action === 'done';
679
634
  const isMemo = action === 'memo';
680
- const usage = `abs todo ${sub} <id>${isDone ? ' [--as 落地|否决|仅方案] [--note "结语"]' : ' --note "…"'}`;
635
+ const usage = `abs todo ${sub} <id>${isDone ? ' [--as 落地|否决|仅方案] [--note "结语"]' : action === 'state' ? ' --note "…" [--n N]' : ' --note "…"'}`;
681
636
  if (isDone) {
682
637
  const note = opts.note || (rest2.length ? rest2.join(' ') : undefined);
683
638
  console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note, as: opts.as, session: opts.session }));
@@ -700,7 +655,10 @@ async function main() {
700
655
  }
701
656
  rejectExtra(rest2, usage);
702
657
  if (!id) throw new Error(`✗ 缺 <id>\n 用法: ${usage}`);
703
- console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note: opts.note, as: opts.as, session: opts.session }));
658
+ // --n:子项位置(state 用它改第 n 条子项,与 child-done 同一套坐标)
659
+ const nKid = opts.n ? Number(opts.n) : undefined;
660
+ if (opts.n && (!Number.isInteger(nKid) || nKid < 1)) throw new Error(`✗ --n 要是正整数(收到 "${opts.n}")`);
661
+ console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note: opts.note, as: opts.as, session: opts.session, n: nKid }));
704
662
  break;
705
663
  }
706
664
  case 'ingest': {
@@ -757,19 +715,6 @@ async function main() {
757
715
  console.log(await cmdRule({ dir: opts.dir, action: isAdd ? 'add' : 'list', text: rest3.join(' ') }));
758
716
  break;
759
717
  }
760
- case 'db': {
761
- const sub = opts._[0];
762
- if (sub === 'rebuild') { console.log(await cmdDbRebuild({ dir: opts.dir })); break; }
763
- if (sub === 'status') { console.log(await cmdDbStatus()); break; }
764
- if (sub === 'evolve') { console.log(await cmdDbEvolve({ staleDays: Number(opts.staleDays) || 90 })); break; }
765
- if (sub === 'add') {
766
- const r = await addProject(opts.dir || process.cwd());
767
- console.log(`✓ 已登记项目: ${r}\n 建库: abs db rebuild`);
768
- break;
769
- }
770
- console.log('用法: abs db rebuild | status | evolve | add [--dir <项目根>]');
771
- break;
772
- }
773
718
  case 'query': {
774
719
  console.log(await cmdQuery({ dir: opts.dir, terms: opts._, includeSuperseded: opts.all }));
775
720
  break;
package/bin/mcp.js CHANGED
@@ -13,8 +13,6 @@ import { join, dirname } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { findBrainRoot, absLogDir, AbsError } from '../src/index.js';
15
15
  import { cmdLoad, cmdStatus, cmdTask, cmdQuery, cmdLint, cmdNote, cmdConcept, cmdRule, cmdResolve, cmdSupersede } from '../src/store.js';
16
- // abs_search 用:db search 的实现(它比 cmdQuery 强 —— 带亮度排序、能扩散、能跨项目)
17
- import { cmdDbSearch } from '../src/brain/db-cmd.js';
18
16
 
19
17
  // ---------- 技术日志: MCP 请求跟踪(调试用, 与图谱 log.md 完全分开) ----------
20
18
  // 落 ~/.abs/log/mcp.log: 每次工具调用一行 [时间] tool cwd 参数摘要 → 耗时/结果摘要。
@@ -115,11 +113,11 @@ tool(
115
113
 
116
114
  tool(
117
115
  'abs_task',
118
- '任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补子项(**追加**,标准 md 嵌套列表,`abs todo note`)/ child_done、child_undone 勾/取消勾子项(n=第几条,缺省最后一条)/ memo 任务随手记(多句/追加/不上看板)/ state 改状态 / done 完成归位 Done(done 时 note=结语文字,as=结语类型:落地|否决|仅方案)。必填: cwd, action, id。多会话并行时,start/add 带上 session(值见 system prompt 的 [会话] 段)以标记「这条任务谁在做」。',
116
+ '任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补子项(**追加**,标准 md 嵌套列表,`abs todo note`)/ child_done、child_undone 勾/取消勾子项(n=第几条,缺省最后一条)/ state 改状态(带 n = 改该任务第 n 条子项,子项也能标四态)/ memo 任务随手记(多句/追加/不上看板)/ done 完成归位 Done(done 时 note=结语文字,as=结语类型:落地|否决|仅方案)。必填: cwd, action, id。多会话并行时,start/add 带上 session(值见 system prompt 的 [会话] 段)以标记「这条任务谁在做」。',
119
117
  {
120
118
  action: z.enum(['add', 'start', 'done', 'note', 'child_done', 'child_undone', 'memo', 'state', 'remove']),
121
119
  id: z.string().describe('任务幂等键,如 TASK-xxx 或子任务名'),
122
- n: z.number().int().positive().optional().describe('child_done/child_undone 用:第几条子项(1-based,缺省=最后一条)'),
120
+ 'n': z.number().int().positive().optional().describe('child_done/child_undone/state 用:第几条子项(1-based,缺省=最后一条;state 不传 n = 改任务本身)'),
123
121
  cwd: z.string().describe('项目根目录(.brain/ 所在处)'), note: z.string().optional().describe('add/start=做什么; note=追加一条子项(改到哪/下一步,一句话); memo=随手记(可多句、不定长,追加式;不传=查看已有笔记); state=进行中|讨论中|滞留中|搁置(搁置=用户改方向/不做了;滞留中=还要做只是卡住); done=结语文字(remove 不用此字段)'),
124
122
  as: z.enum(['落地', '否决', '仅方案']).optional().describe('仅 done:结语类型。默认 落地。做了又撤/评估后不做用 否决,只设计过用 仅方案 —— 别让假【落地】污染看板'),
125
123
  // 会话 id:多会话并行时标记「这条任务是谁在做」。
@@ -209,36 +207,6 @@ tool(
209
207
  }
210
208
  );
211
209
 
212
- tool(
213
- 'abs_search',
214
- '按亮度检索知识图谱(跨项目):比 abs_query 更强 —— 带亮度排序、能从邻居页扩散、能查到别的项目的经验(标 [跨项目])。'
215
- + '适合「我上次怎么解决 X」这类回忆。铁律默认省略(开工时 abs_load 会给),需要时传 include_rules=true。',
216
- {
217
- cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
218
- query: z.string().describe('检索词(一句话或几个词都行,中文两字以上即可)'),
219
- limit: z.number().int().min(1).max(20).optional().describe('最多几页,默认 8'),
220
- include_rules: z.boolean().optional().describe('true=连 L1 铁律一起返回(默认省略,省 token)'),
221
- },
222
- async ({ cwd, query, limit, include_rules }) => {
223
- // 库是全局的(~/.abs/graph.db),不需要 findBrainRoot —— 但先确认项目本身有图谱,
224
- // 否则用户在一个陌生目录调用会得到「别的项目的结果」而不自知。
225
- const root = await findBrainRoot(cwd || process.cwd());
226
- if (!root) return errNoBrain(cwd || process.cwd());
227
- const text = await cmdDbSearch({
228
- query: String(query || ''),
229
- limit: Number(limit) || 8,
230
- noRules: !include_rules,
231
- });
232
- if (/库还没建|无命中/.test(text)) {
233
- return err(text, {
234
- code: 'NO_MATCH',
235
- fallback: '库可能是旧的:跑 abs db rebuild 重扫;或换更具体的词',
236
- });
237
- }
238
- return { content: [{ type: 'text', text }] };
239
- }
240
- );
241
-
242
210
  tool(
243
211
  'abs_rule',
244
212
  '读/写 index.md 的 ## Rules 硬规则区。action=list 列出;action=add 追加一条(只放一句话,≤42 字符、不带链接;展开写概念页)。',
@@ -72,24 +72,28 @@ function dropCols(s: string, n: number): string {
72
72
  }
73
73
 
74
74
  /**
75
- * 推理强度 → 主题色 token。空串表示「不上色,沿用默认色」。
75
+ * 推理强度 → 主色。六档灰蓝(同一色相族、由亮到暗),查不到或 off 时返回空串 = 不上色,用默认色。
76
76
  *
77
- * 规则:`thinking` + 首字母大写,正好对上主题里的 thinkingMinimal…thinkingMax 七档。
78
- * 为什么 off 返回空串而不是 `thinkingOff`:主题给 thinkingOff 的是接近背景的暗灰
79
- * (dark.json: `okhsl(229 8% 49%)`),拿它去染文字会看不清。用户 2026-11-06 定的是
80
- * 「off 时保持默认色」。
77
+ * ⚠ 当前**输入框不再用它**(2026-11-09 用户要求「完全对齐 pi 原版」:边框改用 pi 给的
78
+ * editor.borderColor,即主题那七档 thinking 色)。保留是因为两条:用户在这上面来回改过
79
+ * 两轮(改回去只改 render() 一行),且 todo 面板(abs.pi.ts)另有一份同值副本。
81
80
  *
82
- * ★ 同一份逻辑在 hooks/abs.pi.ts 里也有一份(`effortColorToken`)。不抽公共模块是故意的:
81
+ * 为什么 off 不上色:主题给 thinkingOff 的是接近背景的暗灰(dark.json: `okhsl(229 8% 49%)`),
82
+ * 拿它去染文字会看不清。用户 2026-11-06 定的是「off 时保持默认色」。
83
+ * 色值定于 2026-11-09:先「暗档提亮」把 medium/low/minimal 抬到看得清,试过一版中性灰又还原回蓝调。
84
+ * 对深色背景(#0D1117)对比度 11.84/9.22/7.72/6.53/5.42/4.51。
85
+ *
86
+ * ★ 同一份逻辑在 hooks/abs.pi.ts 里也有一份(`effortAnsi`)。不抽公共模块是故意的:
83
87
  * 安装后 `abs.ts` 与 `abs-rounded-editor/` 是两个可独立删除的扩展,抽成共享文件会让
84
- * 「删掉输入框样式目录」连带把 todo 面板搞崩。
88
+ * 「删掉输入框样式目录」连带把 todo 面板搞崩。改色值时要两边一起改。
85
89
  */
86
90
  export const EFFORT_COLOR: Record<string, { rgb: string; ansi256: number }> = {
87
91
  max: { rgb: '197;205;220', ansi256: 189 },
88
92
  xhigh: { rgb: '168;182;204', ansi256: 152 },
89
93
  high: { rgb: '150;166;198', ansi256: 110 },
90
- medium: { rgb: '122;138;160', ansi256: 110 },
91
- low: { rgb: '94;108;132', ansi256: 103 },
92
- minimal: { rgb: '70;82;106', ansi256: 240 },
94
+ medium: { rgb: '139;153;172', ansi256: 110 },
95
+ low: { rgb: '124;138;162', ansi256: 103 },
96
+ minimal: { rgb: '108;124;157', ansi256: 67 },
93
97
  }
94
98
 
95
99
  export function effortAnsi(level: string | null | undefined, colorMode = 'truecolor'): string {
@@ -98,31 +102,15 @@ export function effortAnsi(level: string | null | undefined, colorMode = 'trueco
98
102
  return colorMode === 'truecolor' ? `38;2;${g.rgb}` : `38;5;${g.ansi256}`
99
103
  }
100
104
 
101
- /** 「搁置」橙 #F59E0B —— 与 todo 面板的 `搁置` 标签同色(abs.pi.ts 的 `C.hold`)。
102
- * 用途(2026-11-08 用户要求):输入框上边框的 working 状态与右上角状态文字。
103
- * ★ 色值在 abs.pi.ts 里也有一份,不抽公共模块是故意的(见 EFFORT_COLOR 的注释)。 */
105
+ /** 「擱置」橙 #F59E0B —— 与 todo 面板的 `[擱置]`、面板大标题同色(abs.pi.ts 的 `C.hold`/`C.title`)。
106
+ * 用途(2026-11-09 用户要求):输入框**右下角的状态文字**(如 `30.0%/1.0M DeepSeek-V4.1-Flash`)。
107
+ * ★ 色值在 abs.pi.ts 里也有一份,不抽公共模块是故意的(见 EFFORT_COLOR 的注释)。 */
104
108
  export const HOLD_COLOR = { rgb: '245;158;11', ansi256: 214 }
105
109
 
106
110
  export function holdAnsi(colorMode = 'truecolor'): string {
107
111
  return colorMode === 'truecolor' ? `38;2;${HOLD_COLOR.rgb}` : `38;5;${HOLD_COLOR.ansi256}`
108
112
  }
109
113
 
110
- /** 上边框最左那段「working 状态」(如 `⠼ Working`)。
111
- *
112
- * 为何要单独把它抠出来(2026-11-08 用户要求它和状态文字同色):那段由 pi 的
113
- * WorkingStatusIndicator 画、夹在 `── ` 与后续虚线之间(见 pi 的 custom-editor
114
- * `renderTopBorder`),而 roundBorder 会先剥掉整行的色再重新上色,
115
- * 不能靠「它原本的颜色」认它。判据取形状:开头 `─+ ` 之后、下一个 `─` 之前的那段。
116
- *
117
- * 排除 `↑ N more` / `↓ N more`(pi 自己的滚动提示):那不是 working 状态,
118
- * 染成橙色就是错色。 */
119
- export function leadSpanOf(plain: string): string {
120
- const m = String(plain).match(/^─+ ([^─]+?) ?─/)
121
- if (!m) return ''
122
- const text = m[1]
123
- return /[↑↓]/.test(text) ? '' : text
124
- }
125
-
126
114
  /** 用 effortAnsi 的结果包一段文字;空串原样返回(off 时不着色)。 */
127
115
  export function paintAnsi(ansi: string, text: string): string {
128
116
  return ansi ? `\x1b[${ansi}m${text}\x1b[39m` : text
@@ -137,9 +125,12 @@ export function paintAnsi(ansi: string, text: string): string {
137
125
  * leftLabel 贴在左圆角之后;label 贴最右端。哪个放不下就丢掉哪个
138
126
  * (宁可没有,也不让边框溢出折行)。
139
127
  *
140
- * opts.leadFg / opts.labelFg(2026-11-08 用户要求):只给「working 状态那段」
141
- * 和「右标签」换个色,其余(圆角、虚线)仍用 fg。不传 opts 时整行一个色,
142
- * 与旧行为逐字相同。
128
+ * opts.labelFg:只给右边那个标签(右下角的状态文字)换个色,其余(圆角、虚线)仍用 fg。
129
+ * 2026-11-09 用户要求该状态文字用擱置橙,调用处传 holdAnsi;不传 opts 时整行一个色。
130
+ *
131
+ * 历史(别再重走):2026-11-08~09 曾有个 leadFg 开关,用来把 pi 嵌进上边框的
132
+ * `⠼ Working` 那段单独染色(橙)。现在 working 已还原成 pi 原版(不嵌边框),
133
+ * leadSpanOf/leadFg 一并删了 —— 要再嵌回去就照那时候的写法加。
143
134
  */
144
135
  export function roundBorder(
145
136
  line: string,
@@ -148,7 +139,7 @@ export function roundBorder(
148
139
  right: string,
149
140
  label = '',
150
141
  leftLabel = '',
151
- opts: { leadFg?: (s: string) => string; labelFg?: (s: string) => string } = {},
142
+ opts: { labelFg?: (s: string) => string } = {},
152
143
  ): string {
153
144
  const plain = stripAnsi(line)
154
145
  if (plain.length === 0) return line
@@ -176,14 +167,12 @@ export function roundBorder(
176
167
  }
177
168
  }
178
169
  return paintSpans(left + mid + right, fg, [
179
- // working 段从**开头**找(它就在最左);右标签从**末尾**找
180
- // (万一同一串又出现在左边,不能把左边那个也染成标签色)。
181
- [leadSpanOf(plain), opts.leadFg, 'first'],
170
+ // 标签从**末尾**找:万一同一串又出现在左边,不能把左边那个也染成标签色。
182
171
  [label, opts.labelFg, 'last'],
183
172
  ])
184
173
  }
185
174
 
186
- /** 把 out 里的几个区间换成各自的色,其余整段用 fg。区间按位置排序、重叠则跳过。
175
+ /** 把 out 里的区间换成各自指定的色,其余整段用 fg。区间按位置排序、重叠则跳过。
187
176
  * 为何不用「拼段」实现:标签与 working 段的字符是从 mid 里苹/插进去的,
188
177
  * 只有拿到最终串才能准确定位(直接拼段会在 dropCols 后错位)。 */
189
178
  function paintSpans(
@@ -3,9 +3,13 @@
3
3
  *
4
4
  * 需求(2026-11-04 用户):
5
5
  * ① 输入框左右都有竖线、四角圆角
6
- * ② working 不单独占一行,只在上边框显示运行图标(embedWorkingStatus)
7
- * ③ 上边框最右侧显示「上下文百分比/上限 模型名」,如 `8.0%/1.0M deepseek-v4.1-flash`
8
- * ④ 输入框下方原有 footer 两行全部隐藏
6
+ * ② 右下角显示「上下文百分比/上限 模型名」,如 `8.0%/1.0M deepseek-v4.1-flash`
7
+ * ③ 输入框下方原有 footer 两行全部隐藏
8
+ *
9
+ * 2026-11-09 两项定案(用户来回试过):
10
+ * ① working 状态**嵌在上边框里**(`embedWorkingStatus: true`),不单独占一行;
11
+ * 颜色跟整行一致 = 框线色(roundBorder 会把整行重新上色,所以那一段不会是另一个色)。
12
+ * ② 边框颜色用 pi 给的 `editor.borderColor`(即原版那七档 thinking 色),框内文字不上色。
9
13
  *
10
14
  * 为什么要覆写 render():
11
15
  * pi 的 Editor.render() 只画上下两条 `─`,每行是「左 padding + 文本 + 右 padding」,
@@ -18,25 +22,15 @@
18
22
  * 注意:自动补全菜单行排在底边之下、且自带内容宽度,不套竖线(否则菜单被框住会错位)。
19
23
  */
20
24
  import { CustomEditor } from '@earendil-works/pi-coding-agent'
21
- import { buildStatus, effortAnsi, holdAnsi, paintAnsi, roundBorder, shortenModel } from './format.ts'
25
+ import { buildStatus, holdAnsi, paintAnsi, roundBorder, shortenModel } from './format.ts'
22
26
 
23
27
  const SIDE = '│'
24
28
 
25
- /** 右上角状态文本,如 `8.0%/1.0M deepseek-v4.1-flash`。render() 拿不到 ctx,故由事件回调维护。 */
29
+ /** 右下角状态文本,如 `8.0%/1.0M deepseek-v4.1-flash`。render() 拿不到 ctx,故由事件回调维护。 */
26
30
  let statusLabel = ''
27
31
 
28
- /** 当前推理强度(off/minimal/low/medium/high/xhigh/max)。render() 拿不到 ctx,同样这里存。
29
- * 用途:输入文字随推理强度变色(2026-11-06 用户需求)。 */
30
- let effortLevel = ''
31
-
32
- /** 取当前主题实例的 getter。
33
- *
34
- * ★ 为何不能把主题从构造函数里拿:pi 传给编辑器了的是 `getEditorTheme()`,
35
- * 那对象只有 `{ borderColor, selectList }`,**没有 `fg`** —— 直接用它上色会静默无效
36
- * (不报错,只是没颜色)。真正带 fg 的是 `ctx.ui.theme`(interactive-mode 的 getter)。
37
- * ★ 为何存 getter 而不是存实例:`/theme` 切主题后 ctx.ui.theme 返回**新实例**,
38
- * 存旧实例会継续用旧主题的色板。 */
39
- let themeGetter: (() => any) | null = null
32
+ /** 终端色模式(truecolor / 256color)。只在 session_start 取一次:它是终端能力,不随切主题改。 */
33
+ let colorMode = 'truecolor'
40
34
 
41
35
  export class RoundedEditor extends CustomEditor {
42
36
  render(width: number): string[] {
@@ -45,69 +39,60 @@ export class RoundedEditor extends CustomEditor {
45
39
  const lines = super.render(width - 2)
46
40
  if (lines.length < 2) return lines
47
41
 
48
- const borderColor = (this as any).borderColor?.bind(this)
49
- // 现代暗色方案(2026-11-08):文字 #F8FAFC,边框 #444C56,不再跟推理强度变色。
50
- // 光标色 / 输入框背景 / 聚焦外发光是 pi 编辑器内部画的,扩展接管不了,先不动。
51
- const colorMode = (() => { try { return String(themeGetter?.().getColorMode()) } catch { return 'truecolor' } })()
52
- const textFg = (s: string) => paintAnsi(effortAnsi(effortLevel, colorMode), s)
53
- const lineFg = textFg
54
- const wrap = lineFg
55
- // working 状态与右上角状态文字用「搁置」橙 #F59E0B(2026-11-08 用户要求):
56
- // 上边框的圆角与虚线仍走正文色,只有这两段橙 —— 一眼区分「还在跑」与上下文占用。
57
- const holdFg = (s: string) => paintAnsi(holdAnsi(colorMode), s)
42
+ // 边框颜色直接用 pi 给编辑器设好的那个(2026-11-09 用户要求「完全对齐原版」):
43
+ // pi 的 interactive-mode 在 updateEditorBorderColor() 里把 editor.borderColor 设成
44
+ // theme.getThinkingBorderColor(档位),bash 模式另给一套 —— 直接复用就等于原版那套
45
+ // (含 off 的暗灰),而且 /theme、切档、切 bash 模式时 pi 自己会换并请求重绘。
46
+ // 框内文字不上色(原版也不上)。光标色 / 输入框背景 / 聚焦外发光是 pi 内部画的,接管不了。
47
+ const border = (this as any).borderColor
48
+ const wrap = typeof border === 'function' ? border.bind(this) : (s: string) => s
58
49
  const out: string[] = []
59
50
 
60
- // 上边框:原本是整行 `─`(含 working 图标与滚动提示),两端换圆角,右端贴状态。
61
- out.push(roundBorder(lines[0], wrap, '╭', '╮', statusLabel, '', { leadFg: holdFg, labelFg: holdFg }))
51
+ // 上边框:原本是整行 `─`(含滚动提示),两端换圆角。
52
+ out.push(roundBorder(lines[0], wrap, '╭', '╮'))
62
53
  // 下边框在原结果的倒数第 (1 + 自动补全行数) 位 —— 补全菜单排在它后面。
63
54
  const autocompleteHeight = (this as any).renderedAutocompleteHeight ?? 0
64
55
  const bottomIdx = lines.length - 1 - autocompleteHeight
65
56
 
66
57
  for (let i = 1; i < bottomIdx; i++) {
67
- // 剥掉 pi 原生文字色,再包 #F8FAFC —— 否则内层色码赢。
68
- const body = lines[i].replace(/\x1b\[(?!7m)[0-9;]*m/g, '')
69
- out.push(wrap(SIDE) + textFg(body) + wrap(SIDE))
58
+ // 内容行原样透传(不上文字色),只在两端各包一根竖线。
59
+ out.push(wrap(SIDE) + lines[i] + wrap(SIDE))
70
60
  }
71
61
  // 下边框(2026-11-07 还原:左下角的 fanchao·昵称已去掉 —— 用户嫌难看,
72
62
  // 回 todo 面板标题;roundBorder 的 leftLabel 参数保留备用)。
73
- out.push(roundBorder(lines[bottomIdx], wrap, '╰', '╯'))
63
+ // 状态文字(`30.0%/1.0M 模型名`)2026-11-09 从右上角搬到右下角,且用户要求上「擱置」橙
64
+ // (只染那一段,圆角与虚线仍是框线色)。
65
+ out.push(roundBorder(lines[bottomIdx], wrap, '╰', '╯', statusLabel, '', {
66
+ labelFg: (s: string) => paintAnsi(holdAnsi(colorMode), s),
67
+ }))
74
68
  // 自动补全行原样透传(不套竖线)。
75
69
  for (let i = bottomIdx + 1; i < lines.length; i++) out.push(lines[i])
76
70
  return out
77
71
  }
78
72
  }
79
73
 
80
- /** 刷新右上角状态文本(每次渲染前由事件与定时器调用)。 */
74
+ /** 刷新右下角状态文本(每次渲染前由事件与定时器调用)。 */
81
75
  function refreshStatus(ctx: any): void {
82
76
  try {
83
77
  statusLabel = buildStatus(ctx?.getContextUsage?.(), ctx?.model?.id ?? '')
84
78
  } catch {
85
79
  statusLabel = shortenModel(ctx?.model?.id ?? '')
86
80
  }
87
- refreshEffort(ctx)
88
- }
89
-
90
- /** 读出当前推理强度。`ctx.thinkingLevel` 是 pi 官方给扩展的入口(effective level)。 */
91
- function refreshEffort(ctx: any): void {
92
- try {
93
- effortLevel = String(ctx?.thinkingLevel || '')
94
- } catch {
95
- effortLevel = ''
96
- }
97
81
  }
98
82
 
99
83
  export default function piRoundedEditor(pi: any): void {
100
84
  pi.on('session_start', (_event: unknown, ctx: any) => {
101
85
  if (!ctx?.ui?.setEditorComponent) return
102
- // 主题 getter(带 fg 的那个)—— 见 themeGetter 注释。
103
- themeGetter = () => ctx.ui?.theme
86
+ // 终端色模式(256 色档位用);取不到就当真彩。
87
+ try { colorMode = String(ctx.ui?.theme?.getColorMode?.() || 'truecolor') } catch { colorMode = 'truecolor' }
104
88
  refreshStatus(ctx)
105
- // 第四个参数 embedWorkingStatus: true —— working 状态进上边框,不再单独占一行。
89
+ // 第四个参数 embedWorkingStatus: true —— working 状态进上边框,不单独占一行(2026-11-09 用户定案)。
90
+ // 颜色不用管:pi 嵌入时用 editor.borderColor,本扩展又把整行重上一次框线色,两边同为档位色。
106
91
  ctx.ui.setEditorComponent(
107
92
  (tui: any, theme: any, keybindings: any) =>
108
93
  new RoundedEditor(tui, theme, keybindings, { embedWorkingStatus: true }),
109
94
  )
110
- // ④ 隐藏输入框下方的 footer 两行(路径/git/token、MCP/扩展状态),信息并入右上角。
95
+ // ③ 隐藏输入框下方的 footer 两行(路径/git/token、MCP/扩展状态),信息并入右下角。
111
96
  ctx.ui.setFooter?.(() => ({ render: () => [], invalidate() {} }))
112
97
  })
113
98
 
@@ -119,13 +104,7 @@ export default function piRoundedEditor(pi: any): void {
119
104
  ctx?.ui?.requestRender?.()
120
105
  })
121
106
 
122
- // 切推理强度时重刷输入框文字色(2026-11-06 用户需求)。
123
- // 为何不能只靠 session_start 那一次:/model 的强度档位、Alt+T 之类的切换不改会话,
124
- // 不重建编辑器也就不会重新读 effortLevel —— 文字会停在旧色。
125
- pi.on('thinking_level_select', (_event: any, ctx: any) => {
126
- refreshEffort(ctx)
127
- ctx?.ui?.requestRender?.()
128
- })
107
+ // 切推理强度:本扩展不再需要处理(边框色是 pi 自己在 updateEditorBorderColor() 里换并请求重绘)。
129
108
 
130
109
  // 上下文占用随每轮对话变化 —— 轮次结束与消息结束时刷新。
131
110
  for (const ev of ['turn_end', 'message_end', 'tool_execution_end']) {
package/hooks/abs.omp.ts CHANGED
@@ -502,40 +502,6 @@ function clipToWidth(s: string, width: number): string {
502
502
  }
503
503
 
504
504
  // 「进行中」行的迷你动画:三个小符号依次由空心变实心。
505
- // 帧宽固定 3 列 —— 宽度不变才不会让面板每帧抖动。
506
- const TODO_ANIM_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
507
- const TODO_ANIM_MS = 100
508
-
509
- /** 动画相位(每 tick +1)。模块级 —— 闭包 flag 在重复注册时不共享。 */
510
- let animPhase = 0
511
-
512
- /** 当前推理强度。模块级理由同上:widget factory 拿不到 ctx。
513
- * omp 宿主有无这个字段未验证 —— 取不到就是空串,面板退回 accent,行为与以前一致。 */
514
- let effortLevel = ''
515
- let animTimer: ReturnType<typeof setInterval> | undefined
516
-
517
- /** 启动/保持动画定时器。重复调用不会叠加(已存在则不动)。
518
- * getTui 是为了每 tick 拿**当前**的 tui 引用 —— widget 可能因刷新被重建。 */
519
- function startAnimTimer(getTui: () => RenderTui | null): void {
520
- if (animTimer) return
521
- animTimer = setInterval(() => {
522
- animPhase++
523
- try { getTui()?.requestRender?.() } catch {}
524
- }, TODO_ANIM_MS)
525
- animTimer.unref?.()
526
- }
527
-
528
- function stopAnimTimer(): void {
529
- if (animTimer) { clearInterval(animTimer); animTimer = undefined }
530
- }
531
-
532
- /** 取当前动画帧。非进行中的行不传 phase(不显示)。 */
533
- export function todoAnimFrame(phase: number): string {
534
- const n = TODO_ANIM_FRAMES.length
535
- const i = ((Math.floor(phase) % n) + n) % n
536
- return TODO_ANIM_FRAMES[i]
537
- }
538
-
539
505
  /**
540
506
  * 推理强度 → 主题色 token。空串表示「不上推理色,用兼底色」。
541
507
  *
@@ -556,7 +522,6 @@ export function renderPanelLines(
556
522
  who: string,
557
523
  width: number,
558
524
  fg: (color: string, s: string) => string,
559
- animPhase = -1,
560
525
  nickname = '',
561
526
  effort = '',
562
527
  ): string[] {
@@ -584,12 +549,11 @@ export function renderPanelLines(
584
549
  // `└─` 只给最后一条 —— 不管它有没有子项(否则末条带子项时全篇没有收尾)。
585
550
  const isLast = i === lastIdx
586
551
  const branch = isLast ? '└─' : '├─'
587
- // 单字符 spinner 紧跟 [进行中](2026-11-06 与 pi 版同步)。
588
- const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg(main, todoAnimFrame(animPhase)) : ''
552
+ // 进行中曾带 spinner(2026-11-06 与 pi 版同步;2026-11-08 用户定「没啥用」与 pi 版一起去掉)。
589
553
  // 复选框 `[ ]`:面板是 todo.md 的只读视图,就该长成 todo 原貌。
590
554
  const box = fg(main, '[ ]')
591
555
  // 状态标签统一跟主色(2026-11-06 与 pi 版同步:靠符号和文字区分状态,不靠颜色)。
592
- const head = `${fg(main, branch)} ${box} ${fg(main, `[${t.state}]`)}${anim} ${fg('text', t.id)}`
556
+ const head = `${fg(main, branch)} ${box} ${fg(main, `[${t.state}]`)} ${fg('text', t.id)}`
593
557
  const desc = t.desc ? ` ${fg('dim', '—')} ${fg(flow, t.desc)}` : ''
594
558
  lines.push(clipToWidth(head + desc, width))
595
559
  if (t.kids && t.kids.length) {
@@ -610,12 +574,11 @@ export function renderPanelLines(
610
574
  }
611
575
 
612
576
  /** 隐藏面板。**必须主动 requestRender** —— 否则数据清了、屏幕还留着旧画面。
613
- * 坑(pi 版 2026-10-05 实报):唯一的重绘入口是动画定时器,而「任务全完成」恰好
614
- * 让 needAnim=false → stopAnimTimer() → 从此没人请求重绘 → 屏幕冻结。
615
- * 即「消失」这个动作把它自己的重绘通道关掉了。 */
577
+ * 坑(pi 版 2026-10-05 实报):当时唯一的重绘入口是动画定时器,而「任务全完成」恰好
578
+ * 让它停掉 → 从此没人请求重绘 → 屏幕冻结。动画已拆(2026-11-08),
579
+ * 但重绘只能靠事件主动请求这个事实没变,少一处就静默冻结。 */
616
580
  function hidePanel(ui: ExtensionUIContext | undefined): void {
617
581
  try { ui?.setWidget(PANEL_KEY, undefined) } catch {}
618
- stopAnimTimer()
619
582
  try { lastTui?.requestRender?.() } catch {}
620
583
  }
621
584
 
@@ -638,18 +601,14 @@ async function refreshTodoPanel(ui: ExtensionUIContext | undefined, cwd: string)
638
601
  lastTui = tui // 供 hidePanel 在「无动画」时也能请求重绘
639
602
  return {
640
603
  render: (width: number) =>
641
- renderPanelLines(data, who, width, (c: string, s: string) => theme.fg(c, s), animPhase, sessionNickname, effortLevel),
604
+ renderPanelLines(data, who, width, (c: string, s: string) => theme.fg(c, s), sessionNickname, effortLevel),
642
605
  invalidate: () => {},
643
606
  }
644
607
  },
645
608
  { placement: 'aboveEditor' },
646
609
  )
647
- // 只在真有「进行中」任务时跑定时器 —— 否则白刷 CPU。
648
- const needAnim = data.rows.some((t) => t.state === '进行中')
649
- if (needAnim) startAnimTimer(() => tuiRef)
650
- else stopAnimTimer()
651
- // 无动画时得自己刷一帧,否则从「有任务」变成「无任务」时屏幕冻结在旧帧。
652
- if (!needAnim) try { lastTui?.requestRender?.() } catch {}
610
+ // 动画已拆(2026-11-08):没有定时器兜底了,每次刷新都得自己请求重绘。
611
+ try { lastTui?.requestRender?.() } catch {}
653
612
  } catch {
654
613
  hidePanel(ui)
655
614
  }
@@ -906,7 +865,6 @@ export default function absOmpExtension(pi: ExtensionAPI): void {
906
865
  })
907
866
 
908
867
  pi.on("session_shutdown", (_e: SessionShutdownEvent, ctx: ExtensionContext) => {
909
- stopAnimTimer() // 面板没了,动画定时器必须一起停(否则残留定时器白刷已丢弃的 tui)
910
868
  snapshotWrapup(ctx.cwd)
911
869
  snapshotSessionPage(ctx.cwd, sessionIdFor(ctx))
912
870
  logHook("session_shutdown").catch(() => {})