@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.
- package/hooks/abs-rounded-editor/format.ts +84 -8
- package/hooks/abs-rounded-editor/index.ts +93 -3
- package/hooks/abs.omp.ts +60 -19
- package/hooks/abs.pi.ts +83 -27
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
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
|
-
//
|
|
112
|
+
// mid 是两端圆角之间的那段;总宽 = 1(左圆角) + mid 宽 + 1(右圆角)。
|
|
54
113
|
let mid = plain
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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(
|
|
562
|
-
|
|
563
|
-
const
|
|
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(
|
|
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(
|
|
572
|
-
const ktext = fg(k.done ? 'dim' :
|
|
573
|
-
lines.push(clipToWidth(`${stem} ${fg(
|
|
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
|
-
|
|
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
|
-
//
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
//
|
|
987
|
-
|
|
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
|
-
|
|
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(
|
|
1003
|
-
|
|
1004
|
-
const
|
|
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(
|
|
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(
|
|
1017
|
-
const ktext = fg(k.done ? 'dim' :
|
|
1018
|
-
lines.push(clipToWidth(`${stem} ${fg(
|
|
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
|
-
//
|
|
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
|
-
// ★
|
|
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
|
-
|
|
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
|
// 留一行日志:否则「有没有生效」只能凭感觉,无法验证。
|