@fanchao8609/agent_brain_sync 1.27.0 → 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.
@@ -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 基线 + 我们的段」拼接后整体返回。
package/hooks/abs.pi.ts CHANGED
@@ -928,17 +928,25 @@ function clipToWidth(s: string, width: number): string {
928
928
  return out + '…'
929
929
  }
930
930
  // ---------------------------------------------------------------------------
931
- // “进行中”行的迷你动画(2026-10-03 用户需求):三个小符号依次由空心变实心。
932
- // 帧宽固定 3 列 —— 宽度不变才不会让面板每帧抖动(这也是不用 ⠋⠙⠹ 那种 spinner 的原因:
933
- // 那个只闪一个符,用户明确要的是“三个点依次点亮”)。
934
- // 平铺 6 帧:亮→全亮→退回(呼吸感),而不是全亮后硬跳回全空。
935
- const TODO_ANIM_FRAMES = ['○○○', '●○○', '●●○', '●●●', '●●○', '●○○']
936
- const TODO_ANIM_MS = 250
931
+ // “进行中”行的迷你动画。
932
+ //
933
+ // 2026-10-03 用户要的是三个点依次点亮;2026-11-06 改成**单字符盲文 spinner**
934
+ // (用户原话:「●○○ 改成一个字符的、动画吧 放在 [进行中] 后面」)。
935
+ //
936
+ // 为何用盲文点阵而不是 ⠋⠙⠹ 之外的 ASCII 旋转符:
937
+ // ⠿ 系符号在等宽字体里都是 1 列宽,且视觉上是个转动的方框,比 | / - \ 均匀。
938
+ // 为何帧宽必须恒定:面板每 250ms 重绘,宽度一变整行就会左右抖。盲文恰好全是 1 列。
939
+ const TODO_ANIM_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
940
+ const TODO_ANIM_MS = 100
937
941
 
938
942
  /** 动画相位(每 tick +1)。模块级 —— 同 agentEndSeen,闭包 flag 在重复注册时不共享。 */
939
943
  let animPhase = 0
940
944
  let animTimer: ReturnType<typeof setInterval> | undefined
941
945
 
946
+ /** 当前推理强度(off/minimal/low/…/max)。模块级,理由同上:widget factory 每帧重新调,
947
+ * 拿不到 ctx,只能由事件回调先存好。2026-11-06 用户需求:面板主色跟推理强度走。 */
948
+ let effortLevel = ''
949
+
942
950
  /** 启动/保持动画定时器。重复调用不会叠加(已存在则不动)。
943
951
  * getTui 是为了每 tick 拿**当前**的 tui 引用 —— widget 可能因刷新被重建,
944
952
  * 持旧的引用会调到一个已被丢弃的 renderer。 */
@@ -963,6 +971,24 @@ export function todoAnimFrame(phase: number): string {
963
971
  return TODO_ANIM_FRAMES[i]
964
972
  }
965
973
 
974
+ /**
975
+ * 推理强度 → 主题色 token。空串表示「不上推理色,用调用方给的兼底色」。
976
+ *
977
+ * 规则:`thinking` + 首字母大写,正好对上主题里的 thinkingMinimal…thinkingMax 七档。
978
+ * 为什么 off 返回空串而不是 `thinkingOff`:主题给 thinkingOff 的是接近背景的暗灰
979
+ * (dark.json: `okhsl(229 8% 49%)`),拿它去染文字会看不清。用户 2026-11-06 定的是
980
+ * 「off 时保持默认色」。
981
+ *
982
+ * ★ 同一份逻辑在 hooks/abs-rounded-editor/format.ts 里也有一份(`thinkingColorToken`)。
983
+ * 不抽公共模块是故意的:安装后 `abs.ts` 与 `abs-rounded-editor/` 是两个可独立删除的扩展,
984
+ * 抽成共享文件会让「删掉输入框样式目录」连带把 todo 面板搞崩。
985
+ */
986
+ export function effortToken(level: string | null | undefined): string {
987
+ const l = String(level || '').trim()
988
+ if (!l || l === 'off') return ''
989
+ return 'thinking' + l[0].toUpperCase() + l.slice(1)
990
+ }
991
+
966
992
  export function renderPanelLines(
967
993
  data: { total: number; rows: { state: string; id: string; desc: string; kids?: { done: boolean; text: string }[] }[]; hidden: number },
968
994
  who: string,
@@ -970,25 +996,37 @@ export function renderPanelLines(
970
996
  fg: (color: string, s: string) => string,
971
997
  animPhase = -1,
972
998
  nickname = '',
999
+ effort = '',
973
1000
  ): string[] {
974
1001
  // ★ 空看板不再直接返回空数组(2026-10-06 用户要求:「即便 todo 没有列表,也要在 pi 上显示」)。
975
1002
  // 理由与看板段一致:面板是「机制在不在」的常驻指示,进去就看不见 = 不知道机制活着。
976
1003
  // 下面仍要画框和标题,所以这里只是不再提前 return。
977
- const colorOf = (state: string): string => (state === '搁置' ? 'dim' : state === '滞留中' ? 'muted' : state === '讨论中' ? 'dim' : 'accent')
1004
+ //
1005
+ // ★ 主色跟推理强度走(2026-11-06 用户需求):标题、[状态]、树线 ├─└─、复选框 [ ]、
1006
+ // 进行中动画同用一色,与 pi 输入框描边/文字同色系。
1007
+ // ★ off 时整块面板用灰阶(同日用户补提:「当推理关闭的时候 todo 面板文字应该全是灰色」)——
1008
+ // 高亮色只在真的开着推理时给,否则「推理关了面板还在发光」让人误以为还在深度思考。
1009
+ // 主色用 muted(中灰),描述与子项降一阶到 dim(深灰),两阶灰保留层次,不一整片平。
1010
+ const off = !effortToken(effort)
1011
+ const main = effortToken(effort) || 'muted'
1012
+ // 描述/未勾子项的颜色:开着推理时 muted(比主色暗、又不至于暗到看不清);
1013
+ // off 时主色已经是 muted,描述得再降一阶用 dim,否则两者同色分不出主次。
1014
+ const flow = off ? 'dim' : 'muted'
978
1015
  const lines: string[] = []
979
1016
  // 上描边行已删除(2026-11-04 用户要求「去掉上边框」)。
980
1017
  // 历史:那行曾用 `thinkingOff` 对齐 pi 输入框描边色(排查三轮才定色)。
981
1018
  // 首行现在是标题行。
982
1019
  //
983
- // 昵称附在作者名后(会话内固定,启动时随机抽)。
984
- const whoPart = who ? who + (nickname ? ` · ${nickname}` : '') : nickname
985
- const title = whoPart ? `Todo (${data.total}) — ${whoPart}` : `Todo (${data.total})`
986
- // 空看板标题前加 1 格(与输入框文字对齐);有任务时不加,任务行顶到最左(2026-11-04 用户)。
987
- lines.push(clipToWidth((data.total === 0 ? ' ' : '') + fg('accent', title), width))
1020
+ // 标题只有计数。**作者名与昵称已从面板去掉**(2026-11-07 用户要求「去掉」)——
1021
+ // 它们改用输入框左下角显示(见 hooks/abs-rounded-editor)。面板不再占这两段宽度,
1022
+ // 窄终端下任务行因此能多显示几个字。
1023
+ // **不要再往标题后面拼作者名/昵称** —— 那就与输入框左下角重复了。
1024
+ const title = `Todo (${data.total})`
1025
+ // 标题也顶最左(2026-11-04 用户)。旧代码曾在空看板时多缩 1 格,2026-11-06 空看板
1026
+ // 已整块不显示(见 refreshTodoPanel),那个分支没意义了。
1027
+ lines.push(clipToWidth(fg(main, title), width))
988
1028
 
989
1029
  const lastIdx = data.rows.length - 1
990
- // 空看板只显示标题行(2026-11-04 用户要求去掉「(无未完成任务)」那行)。
991
- if (data.total === 0) return lines
992
1030
  data.rows.forEach((t, i) => {
993
1031
  // `└─` 只给最后一条 —— 不管它有没有子项。
994
1032
  // (2026-10-03 审查修正:原条件 `i === lastIdx && !t.note` 会让“末条带子项”时
@@ -996,12 +1034,15 @@ export function renderPanelLines(
996
1034
  const isLast = i === lastIdx
997
1035
  const branch = isLast ? '└─' : '├─'
998
1036
  // 进行中的行带一个小动画(其余行不加,避免满屏都在跳)
999
- const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg('accent', todoAnimFrame(animPhase)) : ''
1037
+ // 单字符 spinner 紧跟 [进行中]:靠前放才能一眼看到(放行尾会被长描述挤到前面去看不见)。
1038
+ // 2026-11-06 用户要求:「放在 [进行中] 后面」。
1039
+ const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg(main, todoAnimFrame(animPhase)) : ''
1000
1040
  // 复选框 `[ ]`(2026-10-16 用户要求):面板是 todo.md 的只读视图,
1001
1041
  // 就该长成 todo 原貌 —— 摆成与文件里 `- [ ]` 同形,读到就认得出对应哪一行。
1002
- const box = fg('dim', '[ ]')
1003
- const head = `${fg('dim', branch)} ${box} ${fg(colorOf(t.state), `[${t.state}]`)} ${fg('text', t.id)}${anim}`
1004
- const desc = t.desc ? ` ${fg('dim', '—')} ${fg('muted', t.desc)}` : ''
1042
+ const box = fg(main, '[ ]')
1043
+ // 状态标签统一跟主色(2026-11-06 用户定:靠符号和文字区分状态,不靠颜色)。
1044
+ const head = `${fg(main, branch)} ${box} ${fg(main, `[${t.state}]`)}${anim} ${fg('text', t.id)}`
1045
+ const desc = t.desc ? ` ${fg('dim', '—')} ${fg(flow, t.desc)}` : ''
1005
1046
  lines.push(clipToWidth(head + desc, width))
1006
1047
  if (t.kids && t.kids.length) {
1007
1048
  // 子项:标准 md 嵌套列表 → 画成树
@@ -1009,13 +1050,13 @@ export function renderPanelLines(
1009
1050
  // └─ [x] 子项二
1010
1051
  // 树线前缀 = 父任务的 │(非末条)或空格(末条),与父任务分支对齐。
1011
1052
  // 勾过的用 dim 压暗(一眼看出哪些已完成),未勾用正常色。
1012
- const stem = isLast ? ' ' : fg('dim', '│ ')
1053
+ const stem = isLast ? ' ' : fg(main, '│ ')
1013
1054
  const n = t.kids.length
1014
1055
  t.kids.forEach((k, ki) => {
1015
1056
  const kb = ki === n - 1 ? '└─' : '├─'
1016
- const kbox = fg('dim', k.done ? '[x]' : '[ ]')
1017
- const ktext = fg(k.done ? 'dim' : 'muted', k.text)
1018
- lines.push(clipToWidth(`${stem} ${fg('dim', kb)} ${kbox} ${ktext}`, width))
1057
+ const kbox = fg(main, k.done ? '[x]' : '[ ]')
1058
+ const ktext = fg(k.done ? 'dim' : flow, k.text)
1059
+ lines.push(clipToWidth(`${stem} ${fg(main, kb)} ${kbox} ${ktext}`, width))
1019
1060
  })
1020
1061
  }
1021
1062
  })
@@ -1044,8 +1085,13 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
1044
1085
  const md = await readFile(join(cwd, '.brain', 'todo.md'), 'utf8')
1045
1086
  const who = await currentUser()
1046
1087
  const data = parseOpenTasks(md, PANEL_MAX_ROWS, who)
1047
- // 任务为空**也显示面板**(只留标题行),只有无图谱才隐藏。
1048
- // 曾写 data.total === 0 → hidePanel;用户 2026-10-06 要求改掉。
1088
+ // ★ 无任务就不显示面板(2026-11-06 用户要求,**推翻了 2026-10-06 的旧决定**)。
1089
+ // 旧决定是「即便 todo 没有列表,也要在 pi 上显示」,理由:面板是「机制在不在」的
1090
+ // 常驻指示,看不见就不知道机制活着。用户 2026-11-06 把它改回来了:
1091
+ // 「当没有任务的时候 就别显示 todo 面板了」—— 一行占着编辑器上方是纯噪音。
1092
+ // **不要再改回「空看板也显示标题行」** —— 那就是用户刚提掉的东西。
1093
+ // (想知道机制活没活看别处:abs load 的流水、~/.abs/log/hooks.log。)
1094
+ if (data.total === 0) { hidePanel(ui); return }
1049
1095
  // factory 形式:render(width) 每帧拿真实宽度 → 按宽度截断,不再断字。
1050
1096
  // 动画:phase 存在模块级,render 时读当前值;定时器只在本帧重绘,不重建 widget。
1051
1097
  let tuiRef: any = null
@@ -1063,6 +1109,7 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
1063
1109
  (c: string, s: string) => theme.fg(c, s),
1064
1110
  animPhase,
1065
1111
  sessionNickname,
1112
+ effortLevel,
1066
1113
  ),
1067
1114
  invalidate: () => {},
1068
1115
  }
@@ -1073,8 +1120,7 @@ async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
1073
1120
  const needAnim = data.rows.some((t) => t.state === '进行中')
1074
1121
  if (needAnim) startAnimTimer(() => tuiRef)
1075
1122
  else stopAnimTimer()
1076
- // ★ 无动画时得自己刷一帧(2026-10-06)。
1077
- // 旧代码靠“任务清空→hidePanel”顺带 requestRender;现在空看板不隐藏了,
1123
+ // ★ 无动画时得自己刷一帧。
1078
1124
  // 若这里不主动刷,从“有任务”变成“无任务”时屏幕会冻结在旧帧
1079
1125
  // (标题还写着旧计数)—— 与 2026-10-05 那个静默 bug 同一族。
1080
1126
  // 仍在 needAnim 时不用管:定时器每帧会刷。
@@ -1099,7 +1145,9 @@ export default function absPiHook(pi: ExtensionAPI): void {
1099
1145
  // 本会话的随机昵称 —— 在这里抽一次(不是每帧抽,否则面板会疯狂闪)。
1100
1146
  // 每次 session_start(startup/reload/new/resume/fork)重抽 → “每次打开 pi 都是随机的”。
1101
1147
  sessionNickname = pickNickname()
1102
- logHook(`session_start nickname=${sessionNickname || '(off)'}`).catch(() => {})
1148
+ // 推理强度同样存在模块级:面板主色要跟它走,而 widget factory 拿不到 ctx。
1149
+ effortLevel = String((ctx && ctx.thinkingLevel) || '')
1150
+ logHook(`session_start nickname=${sessionNickname || '(off)'} effort=${effortLevel || '(off)'}`).catch(() => {})
1103
1151
  // 启动/重载时就画出面板(reason=startup|reload|new|resume|fork)。
1104
1152
  // 时序:pi 在 session_start 时已给 ctx.ui(无 UI 时是 noOp,调了不报错),
1105
1153
  // 但设 setWidget 需真的 TUI —— 故用 hasUI 挡一下并留痕,便于判定“没显示”的原因。
@@ -1159,6 +1207,14 @@ export default function absPiHook(pi: ExtensionAPI): void {
1159
1207
  refreshTodoPanel(ctx?.ui, cwd).catch(() => {})
1160
1208
  })
1161
1209
 
1210
+ // 切推理强度 → 面板主色跟着变(2026-11-06 用户需求)。
1211
+ // 不重建 widget、只重读 effortLevel 再刷新:面板数据没变,成本与一次 turn_end 刷一样。
1212
+ pi.on("thinking_level_select", (event: any, ctx: any) => {
1213
+ effortLevel = String(event?.level || (ctx && ctx.thinkingLevel) || "")
1214
+ logHook(`thinking_level_select level=${effortLevel || "(off)"}`).catch(() => {})
1215
+ refreshTodoPanel(ctx?.ui, (ctx && ctx.cwd) || process.cwd()).catch(() => {})
1216
+ })
1217
+
1162
1218
  // todo 及时性验证(2026-10-03):往 system prompt 的 Guidelines 段追加静态条目。
1163
1219
  // 不新增消息、不抢 turn、不产生对话条目 —— 与删掉的两次注入做法本质不同(见文件头)。
1164
1220
  // 留一行日志:否则「有没有生效」只能凭感觉,无法验证。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",